From 09dca8427836420cffc879ce30abe633ad2c449d Mon Sep 17 00:00:00 2001
From: Pavel Feldman <pavel.feldman@gmail.com>
Date: Mon, 23 Mar 2026 13:59:50 -0700
Subject: [PATCH] feat(video): expand annotate config with test status and
 action

---
 docs/src/api/class-overlay.md                 |  55 +++++
 docs/src/api/class-page.md                    |   4 +
 docs/src/api/class-screencast.md              |   8 -
 docs/src/api/class-video.md                   |   7 -
 docs/src/api/params.md                        |   2 -
 docs/src/test-api/class-testoptions.md        |   9 +-
 docs/src/videos.md                            |  10 +-
 examples/todomvc/playwright.config.ts         |   2 +-
 .../todo-creation/add-multiple-todos.spec.ts  |  42 ++--
 packages/injected/src/highlight.css           |  18 +-
 packages/injected/src/highlight.ts            |  80 ++++++-
 packages/injected/src/injectedScript.ts       |  43 +++-
 packages/playwright-client/types/types.d.ts   | 113 +++++-----
 packages/playwright-core/src/client/api.ts    |   1 +
 .../playwright-core/src/client/overlay.ts     |  45 ++++
 packages/playwright-core/src/client/page.ts   |   3 +
 .../playwright-core/src/client/screencast.ts  |   2 +-
 packages/playwright-core/src/client/video.ts  |   4 +-
 .../playwright-core/src/protocol/validator.ts |  45 ++--
 .../src/server/dispatchers/pageDispatcher.ts  |  23 +-
 .../playwright-core/src/server/overlay.ts     | 120 ++++++++++
 packages/playwright-core/src/server/page.ts   |   5 +
 .../playwright-core/src/server/screencast.ts  |  51 +----
 packages/playwright-core/src/server/types.ts  |   2 +-
 .../src/tools/backend/runCode.ts              |  32 ++-
 .../src/tools/cli-client/skill/SKILL.md       |   1 +
 .../skill/references/video-recording.md       |  65 ++++++
 .../src/tools/cli-daemon/commands.ts          |   7 +-
 .../src/utils/isomorphic/protocolMetainfo.ts  |   5 +
 packages/playwright-core/types/types.d.ts     | 113 +++++-----
 packages/playwright/src/common/testType.ts    |   3 +
 packages/playwright/src/index.ts              |  65 +++++-
 packages/playwright/src/worker/testInfo.ts    |   2 +
 packages/playwright/types/test.d.ts           |   7 +-
 packages/protocol/src/channels.d.ts           |  84 ++++---
 packages/protocol/src/protocol.yml            |  47 ++--
 tests/library/overlay.spec.ts                 | 210 ++++++++++++++++++
 tests/mcp/cli-run-code.spec.ts                |  60 +++++
 tests/mcp/run-code.spec.ts                    |  44 ++++
 tests/playwright-test/test-step.spec.ts       |  22 +-
 utils/generate_types/overrides-test.d.ts      |   2 +-
 41 files changed, 1108 insertions(+), 355 deletions(-)
 create mode 100644 docs/src/api/class-overlay.md
 create mode 100644 packages/playwright-core/src/client/overlay.ts
 create mode 100644 packages/playwright-core/src/server/overlay.ts
 create mode 100644 tests/library/overlay.spec.ts
 create mode 100644 tests/mcp/cli-run-code.spec.ts

diff --git a/docs/src/api/class-overlay.md b/docs/src/api/class-overlay.md
new file mode 100644
index 0000000000000..a0b1c1453dac4
--- /dev/null
+++ b/docs/src/api/class-overlay.md
@@ -0,0 +1,55 @@
+# class: Overlay
+* since: v1.59
+
+Interface for managing page overlays that display persistent visual indicators on top of the page.
+
+## async method: Overlay.add
+* since: v1.59
+- returns: <[Disposable]>
+
+Adds an overlay with the given HTML content. The overlay is displayed on top of the page until removed. Returns a disposable that removes the overlay when disposed.
+
+### param: Overlay.add.html
+* since: v1.59
+- `html` <[string]>
+
+HTML content for the overlay.
+
+### option: Overlay.add.timeout
+* since: v1.59
+- `timeout` <[int]>
+
+Optional timeout to remove the decoration after. Decoration stays until dismissed if not provided.
+
+## async method: Overlay.configure
+* since: v1.59
+
+Configures overlay behavior.
+
+### option: Overlay.configure.actionDelay
+* since: v1.59
+- `actionDelay` <[int]>
+
+Delay in milliseconds between actions when overlay is active.
+
+### option: Overlay.configure.actionStyle
+* since: v1.59
+- `actionStyle` <[string]>
+
+CSS style string applied to the action title element displayed during actions.
+
+### option: Overlay.configure.locatorStyle
+* since: v1.59
+- `locatorStyle` <[string]>
+
+CSS style string applied to the locator highlight element displayed during actions.
+
+## async method: Overlay.hide
+* since: v1.59
+
+Hides all overlays without removing them. Overlays can be shown again with [`method: Overlay.show`].
+
+## async method: Overlay.show
+* since: v1.59
+
+Shows previously hidden overlays.
diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md
index 7d41580339296..500813352b220 100644
--- a/docs/src/api/class-page.md
+++ b/docs/src/api/class-page.md
@@ -2759,6 +2759,10 @@ The page's main frame. Page is guaranteed to have a main frame which persists du
 * since: v1.8
 - type: <[Mouse]>
 
+## property: Page.overlay
+* since: v1.59
+- type: <[Overlay]>
+
 ## method: Page.onceDialog
 * since: v1.10
 * langs: java
diff --git a/docs/src/api/class-screencast.md b/docs/src/api/class-screencast.md
index b1205845d8b00..095c800f8827e 100644
--- a/docs/src/api/class-screencast.md
+++ b/docs/src/api/class-screencast.md
@@ -42,14 +42,6 @@ If a screencast is already active (e.g. started by tracing or video recording),
 
 Defaults to 800×800.
 
-### option: Screencast.start.annotate
-* since: v1.59
-* langs: js
-- `annotate` ?<[Object]>
-  - `delay` ?<[int]> How long each annotation is displayed in milliseconds. Defaults to `500`.
-
-If specified, enables visual annotations on interacted elements during screencast. Interacted elements are highlighted with a semi-transparent blue box and click points are shown as red circles.
-
 ## async method: Screencast.stop
 * since: v1.59
 * langs: js
diff --git a/docs/src/api/class-video.md b/docs/src/api/class-video.md
index aac176b557643..945419c3f68c5 100644
--- a/docs/src/api/class-video.md
+++ b/docs/src/api/class-video.md
@@ -144,13 +144,6 @@ Path where the video should be saved when the recording is stopped.
 
 Optional dimensions of the recorded video. If not specified the size will be equal to page viewport scaled down to fit into 800x800. Actual picture of the page will be scaled down if necessary to fit the specified size.
 
-### option: Video.start.annotate
-* since: v1.59
-- `annotate` ?<[Object]>
-  - `delay` ?<[int]> How long each annotation is displayed in milliseconds. Defaults to `500`.
-
-If specified, enables visual annotations on interacted elements during video recording. Interacted elements are highlighted with a semi-transparent blue box and click points are shown as red circles.
-
 ## async method: Video.stop
 * since: v1.59
 
diff --git a/docs/src/api/params.md b/docs/src/api/params.md
index 6e0b59f0ca01a..14ab34518b2fe 100644
--- a/docs/src/api/params.md
+++ b/docs/src/api/params.md
@@ -810,8 +810,6 @@ When set to `minimal`, only record information necessary for routing from HAR. T
     Actual picture of each page will be scaled down if necessary to fit the specified size.
     - `width` <[int]> Video frame width.
     - `height` <[int]> Video frame height.
-  - `annotate` ?<[Object]> If specified, enables visual annotations on interacted elements during video recording.
-    - `delay` ?<[int]> How long each annotation is displayed in milliseconds. Defaults to `500`.
 
 Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. Make
 sure to await [`method: BrowserContext.close`] for videos to be saved.
diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md
index 8b43a9849a431..caf88422a86b7 100644
--- a/docs/src/test-api/class-testoptions.md
+++ b/docs/src/test-api/class-testoptions.md
@@ -625,8 +625,11 @@ export default defineConfig({
   - `size` ?<[Object]> Size of the recorded video. Optional.
     - `width` <[int]>
     - `height` <[int]>
-  - `annotate` ?<[Object]> If specified, visually annotates actions in the video with element highlights and action title subtitles.
-    - `delay` ?<[int]> How long each annotation is displayed in milliseconds. Defaults to `500`.
+  - `annotate` ?<[Object]> If specified, visually annotates the video with test information and action highlights.
+    - `action` ?<[Object]> Controls visual annotations on interacted elements.
+      - `delay` ?<[int]> How long each annotation is displayed in milliseconds. Defaults to `500`.
+    - `test` ?<[Object]> Controls test information displayed as a status overlay in the video.
+      - `level` ?<[TestAnnotationLevel]<"file"|"test"|"step">> Level of the detail to include about the current test.
 
 Whether to record video for each test. Defaults to `'off'`.
 * `'off'`: Do not record video.
@@ -636,7 +639,7 @@ Whether to record video for each test. Defaults to `'off'`.
 
 To control video size, pass an object with `mode` and `size` properties. If video size is not specified, it will be equal to [`property: TestOptions.viewport`] scaled down to fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of each page will be scaled down if necessary to fit the specified size.
 
-To annotate actions in the video with element highlights and action title subtitles, pass `annotate` with an optional `delay` in milliseconds (defaults to `500`).
+To annotate actions in the video, pass `annotate` with `action` and/or `test` sub-options. The `action` option controls visual highlights on interacted elements with an optional `delay` in milliseconds (defaults to `500`). The `test` option controls which test information is displayed as a status overlay.
 
 **Usage**
 
diff --git a/docs/src/videos.md b/docs/src/videos.md
index 6fabf0c123d39..d0351435de595 100644
--- a/docs/src/videos.md
+++ b/docs/src/videos.md
@@ -38,7 +38,7 @@ await context.close();
 
 You can also specify video size and annotation. The video size defaults to the viewport size scaled down to fit 800x800. The video of the viewport is placed in the top-left corner of the output video, scaled down to fit if necessary. You may need to set the viewport size to match your desired video size.
 
-When `annotate` is specified, each action will be visually highlighted in the video with the element outline and action title subtitle. The optional `delay` property controls how long each annotation is displayed (defaults to `500`ms).
+When `action` is specified, each action can be visually highlighted in the video with the element outline and action title subtitle, and test information can be shown as a status overlay. The `action` sub-option controls element highlights with an optional `delay` (defaults to `500`ms). The `test` sub-option controls which test information components are displayed.
 
 ```js tab=js-test title="playwright.config.ts"
 import { defineConfig } from '@playwright/test';
@@ -47,8 +47,11 @@ export default defineConfig({
     video: {
       mode: 'on-first-retry',
       size: { width: 640, height: 480 },
-      annotate: { delay: 500 },
-    }
+      annotate: {
+        action: { delay: 500 },
+        test: { level: 'step' },
+      },
+    },
   },
 });
 ```
@@ -58,7 +61,6 @@ const context = await browser.newContext({
   recordVideo: {
     dir: 'videos/',
     size: { width: 640, height: 480 },
-    annotate: { delay: 500 },
   }
 });
 ```
diff --git a/examples/todomvc/playwright.config.ts b/examples/todomvc/playwright.config.ts
index 5004cd18e24dd..069f00fdda41c 100644
--- a/examples/todomvc/playwright.config.ts
+++ b/examples/todomvc/playwright.config.ts
@@ -47,7 +47,7 @@ export default defineConfig({
 
     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
     trace: 'on-first-retry',
-    video: { mode: 'on', annotate: { delay: 500 } },
+    video: { mode: 'on', annotate: { action: { delay: 500 }, test: { level: 'step' } } },
   },
 
   /* Configure projects for major browsers */
diff --git a/examples/todomvc/tests/todo-creation/add-multiple-todos.spec.ts b/examples/todomvc/tests/todo-creation/add-multiple-todos.spec.ts
index 37c035a240342..253cb27788d47 100644
--- a/examples/todomvc/tests/todo-creation/add-multiple-todos.spec.ts
+++ b/examples/todomvc/tests/todo-creation/add-multiple-todos.spec.ts
@@ -7,30 +7,38 @@ test.describe('Todo Creation', () => {
     // 1. Navigate to the TodoMVC application
     // Expect: The page loads with an empty todo list
     await expect(page.getByRole('textbox', { name: 'What needs to be done?' })).toBeVisible();
-
-    // 2. Add first todo 'Buy groceries' by typing and pressing Enter
     const newTodoInput = page.getByRole('textbox', { name: 'What needs to be done?' });
-    await newTodoInput.fill('Buy groceries');
-    await newTodoInput.press('Enter');
-    // Expect: The first todo appears in the list
-    await expect(page.getByText('Buy groceries')).toBeVisible();
+
+    await test.step('Add first todo', async () => {
+      // 2. Add first todo 'Buy groceries' by typing and pressing Enter
+      await newTodoInput.fill('Buy groceries');
+      await newTodoInput.press('Enter');
+      // Expect: The first todo appears in the list
+      await expect(page.getByText('Buy groceries')).toBeVisible();
+    });
 
     // 3. Add second todo 'Walk the dog' by typing and pressing Enter
-    await newTodoInput.fill('Walk the dog');
-    await newTodoInput.press('Enter');
-    // Expect: The second todo appears in the list below the first
-    await expect(page.getByText('Walk the dog')).toBeVisible();
+    await test.step('Add second todo', async () => {
+      await newTodoInput.fill('Walk the dog');
+      await newTodoInput.press('Enter');
+      // Expect: The second todo appears in the list below the first
+      await expect(page.getByText('Walk the dog')).toBeVisible();
+    });
 
     // 4. Add third todo 'Read a book' by typing and pressing Enter
-    await newTodoInput.fill('Read a book');
-    await newTodoInput.press('Enter');
-    // Expect: The third todo appears in the list below the second
-    await expect(page.getByText('Read a book')).toBeVisible();
+    await test.step('Add third todo', async () => {
+      await newTodoInput.fill('Read a book');
+      await newTodoInput.press('Enter');
+      // Expect: The third todo appears in the list below the second
+      await expect(page.getByText('Read a book')).toBeVisible();
+    });
 
     // Post Conditions: All three todos are visible in the list
-    await expect(page.getByText('Buy groceries')).toBeVisible();
-    await expect(page.getByText('Walk the dog')).toBeVisible();
-    await expect(page.getByText('Read a book')).toBeVisible();
+    await test.step('Post Conditions: Verify all todos are visible', async () => {
+      await expect(page.getByText('Buy groceries')).toBeVisible();
+      await expect(page.getByText('Walk the dog')).toBeVisible();
+      await expect(page.getByText('Read a book')).toBeVisible();
+    });
 
     // Post Conditions: The todo counter shows '3 items left'
     await expect(page.getByText('3 items left')).toBeVisible();
diff --git a/packages/injected/src/highlight.css b/packages/injected/src/highlight.css
index 9c10148a66668..6b79f90313a7a 100644
--- a/packages/injected/src/highlight.css
+++ b/packages/injected/src/highlight.css
@@ -107,23 +107,27 @@ x-pw-action-point {
   z-index: 2;
 }
 
-x-pw-subtitle {
+x-pw-title {
   position: absolute;
-  bottom: 55px;
-  left: 50%;
-  transform: translateX(-50%);
+  top: 6px;
+  right: 6px;
   backdrop-filter: blur(5px);
-  background-color: rgba(0, 0, 0, 0.7);
+  background-color: rgba(0, 0, 0, 0.5);
   color: white;
   border-radius: 6px;
-  padding: 6px 14px;
-  font-size: 14px;
+  padding: 6px;
+  font-size: 24px;
   line-height: 1.4;
   white-space: nowrap;
   user-select: none;
   z-index: 3;
 }
 
+x-pw-user-overlays, x-pw-user-overlay {
+  position: absolute;
+  inset: 0;
+}
+
 @keyframes pw-fade-out {
   from { opacity: 1; }
   to { opacity: 0; }
diff --git a/packages/injected/src/highlight.ts b/packages/injected/src/highlight.ts
index 8c221aac852ff..6f5f9b9c53161 100644
--- a/packages/injected/src/highlight.ts
+++ b/packages/injected/src/highlight.ts
@@ -37,6 +37,7 @@ type RenderedHighlightEntry = {
   tooltipTop?: number,
   tooltipLeft?: number,
   tooltipText?: string,
+  cssStyle?: string,
 };
 
 export type HighlightEntry = {
@@ -46,6 +47,7 @@ export type HighlightEntry = {
   borderColor?: string,
   fadeDuration?: number,
   tooltipText?: string,
+  cssStyle?: string,
 };
 
 export class Highlight {
@@ -53,7 +55,10 @@ export class Highlight {
   private _glassPaneShadow: ShadowRoot;
   private _renderedEntries: RenderedHighlightEntry[] = [];
   private _actionPointElement: HTMLElement;
-  private _subtitleElement: HTMLElement;
+  private _titleElement: HTMLElement;
+  private _userOverlayContainer: HTMLElement;
+  private _userOverlays = new Map<string, HTMLElement>();
+  private _userOverlayHidden = false;
   private _isUnderTest: boolean;
   private _injectedScript: InjectedScript;
   private _rafRequest: number | undefined;
@@ -79,8 +84,10 @@ export class Highlight {
     this._glassPaneElement.style.backgroundColor = 'transparent';
     this._actionPointElement = document.createElement('x-pw-action-point');
     this._actionPointElement.setAttribute('hidden', 'true');
-    this._subtitleElement = document.createElement('x-pw-subtitle');
-    this._subtitleElement.setAttribute('hidden', 'true');
+    this._titleElement = document.createElement('x-pw-title');
+    this._titleElement.setAttribute('hidden', 'true');
+    this._userOverlayContainer = document.createElement('x-pw-user-overlays');
+    this._userOverlayContainer.setAttribute('hidden', 'true');
     this._glassPaneShadow = this._glassPaneElement.attachShadow({ mode: this._isUnderTest ? 'open' : 'closed' });
     // workaround for firefox: when taking screenshots, it complains adoptedStyleSheets.push
     // is not a function, so we fallback to style injection
@@ -94,7 +101,8 @@ export class Highlight {
       this._glassPaneShadow.appendChild(styleElement);
     }
     this._glassPaneShadow.appendChild(this._actionPointElement);
-    this._glassPaneShadow.appendChild(this._subtitleElement);
+    this._glassPaneShadow.appendChild(this._titleElement);
+    this._glassPaneShadow.appendChild(this._userOverlayContainer);
   }
 
   install() {
@@ -148,15 +156,61 @@ export class Highlight {
     this._actionPointElement.hidden = true;
   }
 
-  showSubtitle(text: string, fadeDuration: number) {
-    this._subtitleElement.textContent = text;
-    this._subtitleElement.hidden = false;
-    const fadeTime = fadeDuration / 4;
-    this._subtitleElement.style.animation = `pw-fade-out ${fadeTime}ms ease-out ${fadeDuration - fadeTime}ms forwards`;
+  showActionTitle(text: string, fadeDuration: number, cssStyle?: string) {
+    this._titleElement.textContent = text;
+    this._titleElement.hidden = false;
+    if (fadeDuration) {
+      const fadeTime = fadeDuration / 4;
+      this._titleElement.style.animation = `pw-fade-out ${fadeTime}ms ease-out ${fadeDuration - fadeTime}ms forwards`;
+    } else {
+      this._titleElement.style.animation = '';
+    }
+    if (cssStyle)
+      this._titleElement.style.cssText += ';' + cssStyle;
+  }
+
+  hideActionTitle() {
+    this._titleElement.hidden = true;
+  }
+
+  addUserOverlay(id: string, html: string) {
+    const element = this._injectedScript.document.createElement('div');
+    element.className = 'x-pw-user-overlay';
+    element.innerHTML = html;
+    // Mild sanitization for convenience.
+    for (const script of element.querySelectorAll('script'))
+      script.remove();
+    for (const el of element.querySelectorAll('*')) {
+      for (const attr of [...el.attributes]) {
+        if (attr.name.startsWith('on'))
+          el.removeAttribute(attr.name);
+      }
+    }
+    this._userOverlays.set(id, element);
+    this._userOverlayContainer.appendChild(element);
+    this._userOverlayContainer.hidden = this._userOverlayHidden;
+    return id;
+  }
+
+  removeUserOverlay(id: string) {
+    const element = this._userOverlays.get(id);
+    if (element) {
+      element.remove();
+      this._userOverlays.delete(id);
+    }
+    if (this._userOverlays.size === 0)
+      this._userOverlayContainer.hidden = true;
+  }
+
+  hideUserOverlays() {
+    this._userOverlayHidden = true;
+    this._userOverlayContainer.hidden = true;
   }
 
-  hideSubtitle() {
-    this._subtitleElement.hidden = true;
+  showUserOverlays() {
+    this._userOverlayHidden = false;
+    if (this._userOverlays.size > 0)
+      this._userOverlayContainer.hidden = false;
   }
 
   clearHighlight() {
@@ -196,7 +250,7 @@ export class Highlight {
         lineElement.textContent = entry.tooltipText;
         tooltipElement.appendChild(lineElement);
       }
-      this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, borderColor: entry.borderColor, fadeDuration: entry.fadeDuration, tooltipElement, highlightElement });
+      this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, borderColor: entry.borderColor, fadeDuration: entry.fadeDuration, cssStyle: entry.cssStyle, tooltipElement, highlightElement });
     }
 
     // 2. Trigger layout while positioning tooltips and computing bounding boxes.
@@ -230,6 +284,8 @@ export class Highlight {
         entry.highlightElement.style.border = '2px solid ' + entry.borderColor;
       if (entry.fadeDuration)
         entry.highlightElement.style.animation = `pw-fade-out ${entry.fadeDuration}ms ease-out forwards`;
+      if (entry.cssStyle)
+        entry.highlightElement.style.cssText += ';' + entry.cssStyle;
 
       if (this._isUnderTest)
         console.error('Highlight box for test: ' + JSON.stringify({ x: box.x, y: box.y, width: box.width, height: box.height })); // eslint-disable-line no-console
diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts
index 46ca82ea6b915..1bb25c5a5e692 100644
--- a/packages/injected/src/injectedScript.ts
+++ b/packages/injected/src/injectedScript.ts
@@ -1305,6 +1305,14 @@ export class InjectedScript {
     return this._highlight;
   }
 
+  private _ensureHighlight() {
+    if (!this._highlight) {
+      this._highlight = new Highlight(this);
+      this._highlight.install();
+    }
+    return this._highlight;
+  }
+
   highlight(selector: ParsedSelector) {
     if (!this._highlight) {
       this._highlight = new Highlight(this);
@@ -1313,8 +1321,14 @@ export class InjectedScript {
     this._highlight.runHighlightOnRaf(selector);
   }
 
-  annotate(annotation: { point?: channels.Point, box?: channels.Rect, title?: string, delay?: number }) {
-    const highlight = this._createHighlight();
+  setScreencastAnnotation(annotation: { point?: channels.Point, box?: channels.Rect, actionTitle?: string, delay?: number, actionStyle?: string, locatorStyle?: string } | null) {
+    const highlight = this._ensureHighlight();
+    if (!annotation) {
+      highlight.updateHighlight([]);
+      highlight.hideActionPoint();
+      highlight.hideActionTitle();
+      return;
+    }
     const fadeDuration = annotation.delay ?? 500;
 
     if (annotation.box) {
@@ -1323,12 +1337,33 @@ export class InjectedScript {
         color: 'rgba(0, 128, 255, 0.15)',
         borderColor: 'rgba(0, 128, 255, 0.6)',
         fadeDuration,
+        cssStyle: annotation.locatorStyle,
       }]);
     }
     if (annotation.point)
       highlight.showActionPoint(annotation.point.x, annotation.point.y, fadeDuration);
-    if (annotation.title)
-      highlight.showSubtitle(annotation.title, fadeDuration);
+    if (annotation.actionTitle)
+      highlight.showActionTitle(annotation.actionTitle, fadeDuration, annotation.actionStyle);
+  }
+
+  addUserOverlay(id: string, html: string) {
+    const highlight = this._ensureHighlight();
+    highlight.addUserOverlay(id, html);
+  }
+
+  removeUserOverlay(id: string) {
+    const highlight = this._ensureHighlight();
+    highlight.removeUserOverlay(id);
+  }
+
+  hideUserOverlays() {
+    const highlight = this._ensureHighlight();
+    highlight.hideUserOverlays();
+  }
+
+  showUserOverlays() {
+    const highlight = this._ensureHighlight();
+    highlight.showUserOverlays();
   }
 
   hideHighlight() {
diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts
index 86cfcbd1e5280..83b7f3ae2681d 100644
--- a/packages/playwright-client/types/types.d.ts
+++ b/packages/playwright-client/types/types.d.ts
@@ -5278,6 +5278,8 @@ export interface Page {
 
   mouse: Mouse;
 
+  overlay: Overlay;
+
   /**
    * API testing helper associated with this page. This method returns the same instance as
    * [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) on the
@@ -10194,16 +10196,6 @@ export interface Browser {
          */
         height: number;
       };
-
-      /**
-       * If specified, enables visual annotations on interacted elements during video recording.
-       */
-      annotate?: {
-        /**
-         * How long each annotation is displayed in milliseconds. Defaults to `500`.
-         */
-        delay?: number;
-      };
     };
 
     /**
@@ -15477,16 +15469,6 @@ export interface BrowserType<Unused = {}> {
          */
         height: number;
       };
-
-      /**
-       * If specified, enables visual annotations on interacted elements during video recording.
-       */
-      annotate?: {
-        /**
-         * How long each annotation is displayed in milliseconds. Defaults to `500`.
-         */
-        delay?: number;
-      };
     };
 
     /**
@@ -17350,16 +17332,6 @@ export interface AndroidDevice {
          */
         height: number;
       };
-
-      /**
-       * If specified, enables visual annotations on interacted elements during video recording.
-       */
-      annotate?: {
-        /**
-         * How long each annotation is displayed in milliseconds. Defaults to `500`.
-         */
-        delay?: number;
-      };
     };
 
     /**
@@ -19940,16 +19912,6 @@ export interface Electron {
          */
         height: number;
       };
-
-      /**
-       * If specified, enables visual annotations on interacted elements during video recording.
-       */
-      annotate?: {
-        /**
-         * How long each annotation is displayed in milliseconds. Defaults to `500`.
-         */
-        delay?: number;
-      };
     };
 
     /**
@@ -20863,6 +20825,56 @@ export interface Mouse {
   wheel(deltaX: number, deltaY: number): Promise<void>;
 }
 
+/**
+ * Interface for managing page overlays that display persistent visual indicators on top of the page.
+ */
+export interface Overlay {
+  /**
+   * Adds an overlay with the given HTML content. The overlay is displayed on top of the page until removed. Returns a
+   * disposable that removes the overlay when disposed.
+   * @param html HTML content for the overlay.
+   * @param options
+   */
+  add(html: string, options?: {
+    /**
+     * Optional timeout to remove the decoration after. Decoration stays until dismissed if not provided.
+     */
+    timeout?: number;
+  }): Promise<Disposable>;
+
+  /**
+   * Configures overlay behavior.
+   * @param options
+   */
+  configure(options?: {
+    /**
+     * Delay in milliseconds between actions when overlay is active.
+     */
+    actionDelay?: number;
+
+    /**
+     * CSS style string applied to the action title element displayed during actions.
+     */
+    actionStyle?: string;
+
+    /**
+     * CSS style string applied to the locator highlight element displayed during actions.
+     */
+    locatorStyle?: string;
+  }): Promise<void>;
+
+  /**
+   * Hides all overlays without removing them. Overlays can be shown again with
+   * [overlay.show()](https://playwright.dev/docs/api/class-overlay#overlay-show).
+   */
+  hide(): Promise<void>;
+
+  /**
+   * Shows previously hidden overlays.
+   */
+  show(): Promise<void>;
+}
+
 /**
  * This object can be used to launch or connect to Chromium, returning instances of
  * [Browser](https://playwright.dev/docs/api/class-browser).
@@ -22049,17 +22061,6 @@ export interface Video {
    * @param options
    */
   start(options?: {
-    /**
-     * If specified, enables visual annotations on interacted elements during video recording. Interacted elements are
-     * highlighted with a semi-transparent blue box and click points are shown as red circles.
-     */
-    annotate?: {
-      /**
-       * How long each annotation is displayed in milliseconds. Defaults to `500`.
-       */
-      delay?: number;
-    };
-
     /**
      * Path where the video should be saved when the recording is stopped.
      */
@@ -22965,16 +22966,6 @@ export interface BrowserContextOptions {
        */
       height: number;
     };
-
-    /**
-     * If specified, enables visual annotations on interacted elements during video recording.
-     */
-    annotate?: {
-      /**
-       * How long each annotation is displayed in milliseconds. Defaults to `500`.
-       */
-      delay?: number;
-    };
   };
 
   /**
diff --git a/packages/playwright-core/src/client/api.ts b/packages/playwright-core/src/client/api.ts
index 3a36d03285cef..b40359a1ad9fb 100644
--- a/packages/playwright-core/src/client/api.ts
+++ b/packages/playwright-core/src/client/api.ts
@@ -30,6 +30,7 @@ export { Electron, ElectronApplication } from './electron';
 export { FrameLocator, Locator } from './locator';
 export { ElementHandle } from './elementHandle';
 export { FileChooser } from './fileChooser';
+export type { Overlay } from './overlay';
 export type { Screencast } from './screencast';
 export type { Logger } from './types';
 export { TimeoutError } from './errors';
diff --git a/packages/playwright-core/src/client/overlay.ts b/packages/playwright-core/src/client/overlay.ts
new file mode 100644
index 0000000000000..9c720c31484c2
--- /dev/null
+++ b/packages/playwright-core/src/client/overlay.ts
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { DisposableStub } from './disposable';
+
+import type * as api from '../../types/types';
+import type { Page } from './page';
+
+export class Overlay implements api.Overlay {
+  private readonly _page: Page;
+
+  constructor(page: Page) {
+    this._page = page;
+  }
+
+  async add(html: string, options?: { timeout?: number }): Promise<DisposableStub> {
+    const { id } = await this._page._channel.overlayAdd({ html, timeout: options?.timeout });
+    return new DisposableStub(() => this._page._channel.overlayRemove({ id }));
+  }
+
+  async hide(): Promise<void> {
+    await this._page._channel.overlayHide();
+  }
+
+  async show(): Promise<void> {
+    await this._page._channel.overlayShow();
+  }
+
+  async configure(options: { actionDelay?: number; actionStyle?: string; locatorStyle?: string } = {}): Promise<void> {
+    await this._page._channel.overlayConfigure(options);
+  }
+}
diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts
index 4123cc586e722..6cf27cbda43ef 100644
--- a/packages/playwright-core/src/client/page.ts
+++ b/packages/playwright-core/src/client/page.ts
@@ -31,6 +31,7 @@ import { Keyboard, Mouse, Touchscreen } from './input';
 import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle';
 import { Request, Response, Route, RouteHandler, WebSocket,  WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network';
 import { Video } from './video';
+import { Overlay } from './overlay';
 import { Screencast } from './screencast';
 import { Waiter } from './waiter';
 import { Worker } from './worker';
@@ -95,6 +96,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
   readonly request: APIRequestContext;
   readonly touchscreen: Touchscreen;
   readonly clock: Clock;
+  readonly overlay: Overlay;
   readonly screencast: Screencast;
 
 
@@ -135,6 +137,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
     this._closed = initializer.isClosed;
     this._opener = Page.fromNullable(initializer.opener);
     this._video = new Video(this, this._connection, initializer.video ? Artifact.from(initializer.video) : undefined);
+    this.overlay = new Overlay(this);
     this.screencast = new Screencast(this);
 
     this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding)));
diff --git a/packages/playwright-core/src/client/screencast.ts b/packages/playwright-core/src/client/screencast.ts
index 6133ca44f0168..335b9046cadcb 100644
--- a/packages/playwright-core/src/client/screencast.ts
+++ b/packages/playwright-core/src/client/screencast.ts
@@ -30,7 +30,7 @@ export class Screencast implements api.Screencast {
     });
   }
 
-  async start(onFrame: (frame: { data: Buffer }) => Promise<any>|any, options: { preferredSize?: { width: number, height: number }, annotate?: { delay?: number } } = {}): Promise<DisposableStub> {
+  async start(onFrame: (frame: { data: Buffer }) => Promise<any>|any, options: { preferredSize?: { width: number, height: number } } = {}): Promise<DisposableStub> {
     if (this._onFrame)
       throw new Error('Screencast is already started');
     this._onFrame = onFrame;
diff --git a/packages/playwright-core/src/client/video.ts b/packages/playwright-core/src/client/video.ts
index 7c0d700dcd696..8b3a026d4b069 100644
--- a/packages/playwright-core/src/client/video.ts
+++ b/packages/playwright-core/src/client/video.ts
@@ -35,8 +35,8 @@ export class Video extends EventEmitter implements api.Video {
     this._artifact = artifact;
   }
 
-  async start(options: { path?: string, size?: { width: number, height: number }, annotate?: { delay?: number } } = {}) {
-    const result = await this._page._channel.videoStart({ size: options.size, annotate: options.annotate });
+  async start(options: { path?: string, size?: { width: number, height: number } } = {}) {
+    const result = await this._page._channel.videoStart({ size: options.size });
     this._artifact = Artifact.from(result.artifact);
     this._savePath = options.path;
     return new DisposableStub(() => this.stop());
diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts
index fa937cb42b51b..0e3f875d0265d 100644
--- a/packages/playwright-core/src/protocol/validator.ts
+++ b/packages/playwright-core/src/protocol/validator.ts
@@ -611,9 +611,6 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({
       width: tInt,
       height: tInt,
     })),
-    annotate: tOptional(tObject({
-      delay: tOptional(tInt),
-    })),
   })),
   strictSelectors: tOptional(tBoolean),
   serviceWorkers: tOptional(tEnum(['allow', 'block'])),
@@ -725,9 +722,6 @@ scheme.BrowserNewContextParams = tObject({
       width: tInt,
       height: tInt,
     })),
-    annotate: tOptional(tObject({
-      delay: tOptional(tInt),
-    })),
   })),
   strictSelectors: tOptional(tBoolean),
   serviceWorkers: tOptional(tEnum(['allow', 'block'])),
@@ -799,9 +793,6 @@ scheme.BrowserNewContextForReuseParams = tObject({
       width: tInt,
       height: tInt,
     })),
-    annotate: tOptional(tObject({
-      delay: tOptional(tInt),
-    })),
   })),
   strictSelectors: tOptional(tBoolean),
   serviceWorkers: tOptional(tEnum(['allow', 'block'])),
@@ -920,9 +911,6 @@ scheme.BrowserContextInitializer = tObject({
         width: tInt,
         height: tInt,
       })),
-      annotate: tOptional(tObject({
-        delay: tOptional(tInt),
-      })),
     })),
     strictSelectors: tOptional(tBoolean),
     serviceWorkers: tOptional(tEnum(['allow', 'block'])),
@@ -1551,14 +1539,32 @@ scheme.PagePickLocatorResult = tObject({
 });
 scheme.PageCancelPickLocatorParams = tOptional(tObject({}));
 scheme.PageCancelPickLocatorResult = tOptional(tObject({}));
+scheme.PageOverlayAddParams = tObject({
+  html: tString,
+  timeout: tOptional(tInt),
+});
+scheme.PageOverlayAddResult = tObject({
+  id: tString,
+});
+scheme.PageOverlayRemoveParams = tObject({
+  id: tString,
+});
+scheme.PageOverlayRemoveResult = tOptional(tObject({}));
+scheme.PageOverlayHideParams = tOptional(tObject({}));
+scheme.PageOverlayHideResult = tOptional(tObject({}));
+scheme.PageOverlayShowParams = tOptional(tObject({}));
+scheme.PageOverlayShowResult = tOptional(tObject({}));
+scheme.PageOverlayConfigureParams = tObject({
+  actionDelay: tOptional(tInt),
+  actionStyle: tOptional(tString),
+  locatorStyle: tOptional(tString),
+});
+scheme.PageOverlayConfigureResult = tOptional(tObject({}));
 scheme.PageStartScreencastParams = tObject({
   preferredSize: tOptional(tObject({
     width: tInt,
     height: tInt,
   })),
-  annotate: tOptional(tObject({
-    delay: tOptional(tInt),
-  })),
 });
 scheme.PageStartScreencastResult = tOptional(tObject({}));
 scheme.PageStopScreencastParams = tOptional(tObject({}));
@@ -1568,9 +1574,6 @@ scheme.PageVideoStartParams = tObject({
     width: tInt,
     height: tInt,
   })),
-  annotate: tOptional(tObject({
-    delay: tOptional(tInt),
-  })),
 });
 scheme.PageVideoStartResult = tObject({
   artifact: tChannel(['Artifact']),
@@ -2670,9 +2673,6 @@ scheme.ElectronLaunchParams = tObject({
       width: tInt,
       height: tInt,
     })),
-    annotate: tOptional(tObject({
-      delay: tOptional(tInt),
-    })),
   })),
   strictSelectors: tOptional(tBoolean),
   timezoneId: tOptional(tString),
@@ -2908,9 +2908,6 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({
       width: tInt,
       height: tInt,
     })),
-    annotate: tOptional(tObject({
-      delay: tOptional(tInt),
-    })),
   })),
   strictSelectors: tOptional(tBoolean),
   serviceWorkers: tOptional(tEnum(['allow', 'block'])),
diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts
index 8657f1b4d67e6..7d292cd18bab3 100644
--- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts
+++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts
@@ -355,6 +355,27 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
     await recorder?.setMode('none');
   }
 
+  async overlayAdd(params: channels.PageOverlayAddParams): Promise<channels.PageOverlayAddResult> {
+    const id = await this._page.overlay.add(params.html, params.timeout);
+    return { id };
+  }
+
+  async overlayRemove(params: channels.PageOverlayRemoveParams): Promise<channels.PageOverlayRemoveResult> {
+    await this._page.overlay.remove(params.id);
+  }
+
+  async overlayHide(params: channels.PageOverlayHideParams): Promise<channels.PageOverlayHideResult> {
+    await this._page.overlay.hide();
+  }
+
+  async overlayShow(params: channels.PageOverlayShowParams): Promise<channels.PageOverlayShowResult> {
+    await this._page.overlay.show();
+  }
+
+  async overlayConfigure(params: channels.PageOverlayConfigureParams): Promise<channels.PageOverlayConfigureResult> {
+    await this._page.overlay.configure(params);
+  }
+
   async startScreencast(params: channels.PageStartScreencastParams, progress?: Progress): Promise<channels.PageStartScreencastResult> {
     if (this._screencastListener)
       throw new Error('Screencast is already running');
@@ -362,7 +383,7 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
     this._screencastListener = (frame: ScreencastFrame) => {
       this._dispatchEvent('screencastFrame', { data: frame.buffer });
     };
-    await this._page.screencast.startScreencast(this._screencastListener, { quality: 90, width: size.width, height: size.height, annotate: params.annotate });
+    await this._page.screencast.startScreencast(this._screencastListener, { quality: 90, width: size.width, height: size.height });
   }
 
   async stopScreencast(params: channels.PageStopScreencastParams, progress?: Progress): Promise<channels.PageStopScreencastResult> {
diff --git a/packages/playwright-core/src/server/overlay.ts b/packages/playwright-core/src/server/overlay.ts
new file mode 100644
index 0000000000000..6b772acfc7442
--- /dev/null
+++ b/packages/playwright-core/src/server/overlay.ts
@@ -0,0 +1,120 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createGuid, debugLogger } from '../utils';
+import { renderTitleForCall } from '../utils';
+import { Page } from './page';
+
+import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
+
+export class Overlay implements InstrumentationListener {
+  private readonly _page: Page;
+  private _options: { actionDelay?: number; actionStyle?: string; locatorStyle?: string; } | undefined;
+  private _overlays = new Map<string, string>();
+
+  constructor(page: Page) {
+    this._page = page;
+    this._page.instrumentation.addListener(this, page.browserContext);
+    this._page.on(Page.Events.InternalFrameNavigatedToNewDocument, () => {
+      for (const [id, html] of this._overlays)
+        this._doAdd(id, html).catch(e => debugLogger.log('error', e));
+    });
+  }
+
+  dispose() {
+    this._page.instrumentation.removeListener(this);
+  }
+
+  async add(html: string, timeout?: number): Promise<string> {
+    const id = createGuid();
+    this._overlays.set(id, html);
+    // We must return id even if this call fails.
+    await this._doAdd(id, html).catch(e => debugLogger.log('error', e));
+    if (timeout) {
+      await new Promise(f => setTimeout(f, timeout));
+      await this.remove(id);
+    }
+    return id;
+  }
+
+  private async _doAdd(id: string, html: string) {
+    const utility = await this._page.mainFrame()._utilityContext();
+    await utility.evaluate(({ injected, html, id }) => {
+      return injected.addUserOverlay(id, html);
+    }, { injected: await utility.injectedScript(), html, id });
+  }
+
+  async remove(id: string): Promise<void> {
+    this._overlays.delete(id);
+    const utility = await this._page.mainFrame()._utilityContext();
+    await utility.evaluate(({ injected, id }) => {
+      injected.removeUserOverlay(id);
+    }, { injected: await utility.injectedScript(), id }).catch(e => debugLogger.log('error', e));
+  }
+
+  async hide(): Promise<void> {
+    const utility = await this._page.mainFrame()._utilityContext();
+    await utility.evaluate(({ injected }) => {
+      injected.hideUserOverlays();
+    }, { injected: await utility.injectedScript() }).catch(e => debugLogger.log('error', e));
+  }
+
+  async show(): Promise<void> {
+    const utility = await this._page.mainFrame()._utilityContext();
+    await utility.evaluate(({ injected }) => {
+      injected.showUserOverlays();
+    }, { injected: await utility.injectedScript() }).catch(e => debugLogger.log('error', e));
+  }
+
+  async configure(options: { actionDelay?: number; actionStyle?: string; locatorStyle?: string }): Promise<void> {
+    this._options = options;
+  }
+
+  async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata, parentId?: string): Promise<void> {
+    if (!this._options?.actionDelay)
+      return;
+    metadata.annotate = true;
+  }
+
+  async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
+    if (!this._options?.actionDelay)
+      return;
+
+    const page = sdkObject.attribution.page;
+    if (!page)
+      return;
+
+    const actionTitle = renderTitleForCall(metadata);
+    const utility = await page.mainFrame()._utilityContext();
+
+    // Run this outside of the progress timer.
+    await utility.evaluate(async options => {
+      const { injected, delay } = options;
+      injected.setScreencastAnnotation(options);
+      await new Promise(f => injected.utils.builtins.setTimeout(f, delay));
+      injected.setScreencastAnnotation(null);
+    }, {
+      injected: await utility.injectedScript(),
+      delay: this._options?.actionDelay,
+      point: metadata.point,
+      box: metadata.box,
+      actionTitle,
+      actionStyle: this._options?.actionStyle,
+      locatorStyle: this._options?.locatorStyle,
+    }).catch(e => debugLogger.log('error', e));
+  }
+
+}
diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts
index ab3c130cee79e..6de44b59d6a9f 100644
--- a/packages/playwright-core/src/server/page.ts
+++ b/packages/playwright-core/src/server/page.ts
@@ -35,6 +35,7 @@ import { ManualPromise } from '../utils/isomorphic/manualPromise';
 import { parseEvaluationResultValue } from '../utils/isomorphic/utilityScriptSerializers';
 import { compressCallLog } from './callLog';
 import * as rawBindingsControllerSource from '../generated/bindingsControllerSource';
+import { Overlay } from './overlay';
 import { Screencast } from './screencast';
 import { NonRecoverableDOMError } from './dom';
 
@@ -189,6 +190,7 @@ export class Page extends SdkObject<PageEventMap> {
   private _locatorHandlerRunningCounter = 0;
   private _networkRequests: network.Request[] = [];
 
+  readonly overlay: Overlay;
   readonly screencast: Screencast;
   _closeReason: string | undefined;
 
@@ -202,6 +204,7 @@ export class Page extends SdkObject<PageEventMap> {
     this.touchscreen = new input.Touchscreen(delegate.rawTouchscreen, this);
     this.screenshotter = new Screenshotter(this);
     this.frameManager = new frames.FrameManager(this);
+    this.overlay = new Overlay(this);
     this.screencast = new Screencast(this);
     if (delegate.pdf)
       this.pdf = delegate.pdf.bind(delegate);
@@ -280,6 +283,7 @@ export class Page extends SdkObject<PageEventMap> {
   _didClose() {
     this.frameManager.dispose();
     this.screencast.dispose();
+    this.overlay.dispose();
     assert(this._closedState !== 'closed', 'Page closed twice');
     this._closedState = 'closed';
     this.emit(Page.Events.Close);
@@ -292,6 +296,7 @@ export class Page extends SdkObject<PageEventMap> {
   _didCrash() {
     this.frameManager.dispose();
     this.screencast.dispose();
+    this.overlay.dispose();
     this.emit(Page.Events.Crash);
     this._crashed = true;
     this.instrumentation.onPageClose(this);
diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts
index d518eba615f15..646827e8ac3fe 100644
--- a/packages/playwright-core/src/server/screencast.ts
+++ b/packages/playwright-core/src/server/screencast.ts
@@ -15,7 +15,7 @@
  */
 
 import path from 'path';
-import { assert, createGuid, renderTitleForCall } from '../utils';
+import { assert, createGuid } from '../utils';
 import { debugLogger } from '../utils';
 import { VideoRecorder } from './videoRecorder';
 import { Page } from './page';
@@ -23,12 +23,11 @@ import { registry } from './registry';
 import { validateVideoSize } from './browserContext';
 
 import type * as types from './types';
-import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
 
 export type ScreencastListener = (frame: types.ScreencastFrame) => void;
-export type ScreencastOptions = { width: number, height: number, quality: number, annotate?: types.AnnotateOptions };
+export type ScreencastOptions = { width: number, height: number, quality: number };
 
-export class Screencast implements InstrumentationListener {
+export class Screencast {
   private _page: Page;
   private _videoRecorder: VideoRecorder | null = null;
   private _videoId: string | null = null;
@@ -37,17 +36,14 @@ export class Screencast implements InstrumentationListener {
   // When throttling for tracing, 200ms between frames, except for 10 frames around the action.
   private _frameThrottler: FrameThrottler | undefined;
   private _videoFrameListener: ScreencastListener | null = null;
-  private _annotate: types.AnnotateOptions | undefined;
 
   constructor(page: Page) {
     this._page = page;
-    this._page.instrumentation.addListener(this, page.browserContext);
   }
 
   dispose() {
     this._frameThrottler?.dispose();
     this._frameThrottler = undefined;
-    this._page.instrumentation.removeListener(this);
   }
 
   startForTracing(listener: ScreencastListener) {
@@ -78,10 +74,7 @@ export class Screencast implements InstrumentationListener {
     if (!recordVideo)
       return;
     // validateBrowserContextOptions ensures correct video size.
-    const videoOptions = this._launchVideoRecorder(recordVideo.dir, recordVideo.size!);
-    if (recordVideo.annotate)
-      videoOptions.annotate = recordVideo.annotate;
-    return videoOptions;
+    return this._launchVideoRecorder(recordVideo.dir, recordVideo.size!);
   }
 
   private _launchVideoRecorder(dir: string, size: { width: number, height: number }): types.VideoOptions {
@@ -113,7 +106,6 @@ export class Screencast implements InstrumentationListener {
       quality: 90,
       width: options.width,
       height: options.height,
-      annotate: options.annotate,
     });
     return this._page.browserContext._browser._videoStarted(this._page, videoId, options.outputFile);
   }
@@ -153,8 +145,6 @@ export class Screencast implements InstrumentationListener {
 
   async startScreencast(listener: ScreencastListener, options: ScreencastOptions) {
     this._clients.set(listener, options);
-    if (!this._annotate && options.annotate)
-      this._annotate = options.annotate;
     if (this._clients.size === 1)
       await this._page.delegate.startScreencast(options);
   }
@@ -163,45 +153,12 @@ export class Screencast implements InstrumentationListener {
     this._clients.delete(listener);
     if (!this._clients.size)
       await this._page.delegate.stopScreencast();
-    this._annotate = Array.from(this._clients.values()).find(options => options.annotate)?.annotate;
   }
 
   onScreencastFrame(frame: types.ScreencastFrame) {
     for (const listener of this._clients.keys())
       listener(frame);
   }
-
-  async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata, parentId?: string): Promise<void> {
-    if (!this._annotate)
-      return;
-    metadata.annotate = true;
-  }
-
-  async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
-    if (!this._annotate)
-      return;
-
-    const page = sdkObject.attribution.page;
-    if (!page)
-      return;
-
-    const title = renderTitleForCall(metadata);
-    const utility = await page.mainFrame()._utilityContext();
-
-    // Run this outside of the progress timer.
-    await utility.evaluate(async options => {
-      const { injected, delay } = options;
-      injected.annotate(options);
-      await new Promise(f => injected.utils.builtins.setTimeout(f, delay));
-      injected.hideHighlight();
-    }, {
-      injected: await utility.injectedScript(),
-      ...this._annotate,
-      point: metadata.point,
-      box: metadata.box,
-      title,
-    }).catch(e => debugLogger.log('error', e));
-  }
 }
 
 class FrameThrottler {
diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts
index ddb292a290753..1903a9543260a 100644
--- a/packages/playwright-core/src/server/types.ts
+++ b/packages/playwright-core/src/server/types.ts
@@ -44,7 +44,7 @@ export type PointerActionWaitOptions = CommonActionOptions & {
 };
 
 export type AnnotateOptions = {
-  delay?: number,
+  action?: { delay?: number },
 };
 
 export type VideoOptions = {
diff --git a/packages/playwright-core/src/tools/backend/runCode.ts b/packages/playwright-core/src/tools/backend/runCode.ts
index 7602aa86d69c0..0b137bc7e51d5 100644
--- a/packages/playwright-core/src/tools/backend/runCode.ts
+++ b/packages/playwright-core/src/tools/backend/runCode.ts
@@ -14,6 +14,7 @@
  * limitations under the License.
  */
 
+import fs from 'fs';
 import vm from 'vm';
 
 import { ManualPromise } from '../../utils/isomorphic/manualPromise';
@@ -22,7 +23,8 @@ import { z } from '../../mcpBundle';
 import { defineTabTool } from './tool';
 
 const codeSchema = z.object({
-  code: z.string().describe(`A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: \`async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }\``),
+  code: z.string().optional().describe(`A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: \`async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }\``),
+  filename: z.string().optional().describe('Load code from the specified file. If both code and filename are provided, code will be ignored.'),
 });
 
 const runCode = defineTabTool({
@@ -36,22 +38,30 @@ const runCode = defineTabTool({
   },
 
   handle: async (tab, params, response) => {
-    response.addCode(`await (${params.code})(page);`);
+    let code = params.code;
+    if (params.filename) {
+      const resolvedPath = await response.resolveClientFilename(params.filename);
+      code = await fs.promises.readFile(resolvedPath, 'utf-8');
+    }
+    response.addCode(`await (${code})(page);`);
     const __end__ = new ManualPromise<void>();
-    const context = {
+    const context: any = {
       page: tab.page,
       __end__,
     };
     vm.createContext(context);
     await tab.waitForCompletion(async () => {
-      const snippet = `(async () => {
-        try {
-          const result = await (${params.code})(page);
-          __end__.resolve(JSON.stringify(result));
-        } catch (e) {
-          __end__.reject(e);
-        }
-      })()`;
+      // Compile the user function separately to avoid template literal escaping issues
+      // when the code contains backticks.
+      context.__fn__ = vm.runInContext('(' + code + ')', context);
+      const snippet = '(async () => {\n' +
+          '  try {\n' +
+          '    const result = await __fn__(page);\n' +
+          '    __end__.resolve(JSON.stringify(result));\n' +
+          '  } catch (e) {\n' +
+          '    __end__.reject(e);\n' +
+          '  }\n' +
+          '})()';
       await vm.runInContext(snippet, context);
       const result = await __end__;
       if (typeof result === 'string')
diff --git a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md
index 121aa9a74ec4a..cac4db32a2d55 100644
--- a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md
+++ b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md
@@ -152,6 +152,7 @@ playwright-cli console
 playwright-cli console warning
 playwright-cli network
 playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
+playwright-cli run-code --filename=script.js
 playwright-cli tracing-start
 playwright-cli tracing-stop
 playwright-cli video-start
diff --git a/packages/playwright-core/src/tools/cli-client/skill/references/video-recording.md b/packages/playwright-core/src/tools/cli-client/skill/references/video-recording.md
index 2501cace5c828..29a0776b66b7e 100644
--- a/packages/playwright-core/src/tools/cli-client/skill/references/video-recording.md
+++ b/packages/playwright-core/src/tools/cli-client/skill/references/video-recording.md
@@ -31,6 +31,71 @@ playwright-cli video-stop --filename=recordings/login-flow-2024-01-15.webm
 playwright-cli video-stop --filename=recordings/checkout-test-run-42.webm
 ```
 
+### 2. Record entire hero scripts.
+
+When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it.
+It allows pulling appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
+
+1) Perform scenario using CLI and take note of all locators and actions
+2) Create a file with the intended script for video (below)
+3) Use playwright-cli run-code with it
+
+```js
+async page => {
+  await page.video().start({ path: 'video.webm', size: { width: 1280, height: 800 } });
+  await page.goto('https://demo.playwright.dev/todomvc');
+
+  // Render big message, can be much prettier, this is just a basic version.
+  await page.overlay.add(`
+    <div style="position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translateX(-50%);
+      padding: 6px;
+      background: #808080A0;
+      border-radius: 10px;
+      font-size: 24px;
+      color: white;">1. Add the first item</div>
+  `, { timeout: 2000 });
+
+  // Perform action
+  await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog');
+  await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
+
+  // Wait a bit for user to see what happened.
+  await page.waitForTimeout(2000);
+
+  // Now annotate what happened right on the screen.
+  const bounds = await page.getByText('Walk the dog').boundingBox();
+  await page.overlay.add(`
+    <div style="position: absolute;
+      top: ${bounds.y}px;
+      left: ${bounds.x}px;
+      width: ${bounds.width}px;
+      height: ${bounds.height}px;
+      border: 1px solid red;">
+    </div>
+    <div style="position: absolute;
+      top: ${bounds.y + bounds.height + 5}px;
+      left: ${bounds.x + bounds.width / 2}px;
+      transform: translateX(-50%);
+      padding: 6px;
+      background: #808080A0;
+      border-radius: 10px;
+      font-size: 14px;
+      color: white;">Check it out, it is right above this text
+    </div>
+  `, { timeout: 2000 });
+
+
+  // Alternatively, you can add sticky overlays and remove them explicitly:
+  const o1 = await page.overlay.add('...');
+  // Perform actions...
+  await o1.dispose();
+  await page.video().stop();
+}
+```
+
 ## Tracing vs Video
 
 | Feature | Video | Tracing |
diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts
index fe0d8dbb3910b..ab018b86cbb45 100644
--- a/packages/playwright-core/src/tools/cli-daemon/commands.ts
+++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts
@@ -405,10 +405,13 @@ const runCode = declareCommand({
   description: 'Run Playwright code snippet',
   category: 'devtools',
   args: z.object({
-    code: z.string().describe('A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction.'),
+    code: z.string().optional().describe('A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction.'),
+  }),
+  options: z.object({
+    filename: z.string().optional().describe('Load code from the specified file.'),
   }),
   toolName: 'browser_run_code',
-  toolParams: ({ code }) => ({ code }),
+  toolParams: ({ code, filename }) => ({ code, filename }),
 });
 
 // Tabs
diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts
index 819dfd0aa5dbf..18943d56579e4 100644
--- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts
+++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts
@@ -148,6 +148,11 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
   ['Page.bringToFront', { title: 'Bring to front', }],
   ['Page.pickLocator', { title: 'Pick locator', group: 'configuration', }],
   ['Page.cancelPickLocator', { title: 'Cancel pick locator', group: 'configuration', }],
+  ['Page.overlayAdd', { title: 'Add overlay', group: 'configuration', }],
+  ['Page.overlayRemove', { title: 'Remove overlay', group: 'configuration', }],
+  ['Page.overlayHide', { title: 'Hide overlay', group: 'configuration', }],
+  ['Page.overlayShow', { title: 'Show overlay', group: 'configuration', }],
+  ['Page.overlayConfigure', { title: 'Configure overlay', group: 'configuration', }],
   ['Page.startScreencast', { title: 'Start screencast', group: 'configuration', }],
   ['Page.stopScreencast', { title: 'Stop screencast', group: 'configuration', }],
   ['Page.videoStart', { title: 'Start video recording', group: 'configuration', }],
diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts
index 86cfcbd1e5280..83b7f3ae2681d 100644
--- a/packages/playwright-core/types/types.d.ts
+++ b/packages/playwright-core/types/types.d.ts
@@ -5278,6 +5278,8 @@ export interface Page {
 
   mouse: Mouse;
 
+  overlay: Overlay;
+
   /**
    * API testing helper associated with this page. This method returns the same instance as
    * [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) on the
@@ -10194,16 +10196,6 @@ export interface Browser {
          */
         height: number;
       };
-
-      /**
-       * If specified, enables visual annotations on interacted elements during video recording.
-       */
-      annotate?: {
-        /**
-         * How long each annotation is displayed in milliseconds. Defaults to `500`.
-         */
-        delay?: number;
-      };
     };
 
     /**
@@ -15477,16 +15469,6 @@ export interface BrowserType<Unused = {}> {
          */
         height: number;
       };
-
-      /**
-       * If specified, enables visual annotations on interacted elements during video recording.
-       */
-      annotate?: {
-        /**
-         * How long each annotation is displayed in milliseconds. Defaults to `500`.
-         */
-        delay?: number;
-      };
     };
 
     /**
@@ -17350,16 +17332,6 @@ export interface AndroidDevice {
          */
         height: number;
       };
-
-      /**
-       * If specified, enables visual annotations on interacted elements during video recording.
-       */
-      annotate?: {
-        /**
-         * How long each annotation is displayed in milliseconds. Defaults to `500`.
-         */
-        delay?: number;
-      };
     };
 
     /**
@@ -19940,16 +19912,6 @@ export interface Electron {
          */
         height: number;
       };
-
-      /**
-       * If specified, enables visual annotations on interacted elements during video recording.
-       */
-      annotate?: {
-        /**
-         * How long each annotation is displayed in milliseconds. Defaults to `500`.
-         */
-        delay?: number;
-      };
     };
 
     /**
@@ -20863,6 +20825,56 @@ export interface Mouse {
   wheel(deltaX: number, deltaY: number): Promise<void>;
 }
 
+/**
+ * Interface for managing page overlays that display persistent visual indicators on top of the page.
+ */
+export interface Overlay {
+  /**
+   * Adds an overlay with the given HTML content. The overlay is displayed on top of the page until removed. Returns a
+   * disposable that removes the overlay when disposed.
+   * @param html HTML content for the overlay.
+   * @param options
+   */
+  add(html: string, options?: {
+    /**
+     * Optional timeout to remove the decoration after. Decoration stays until dismissed if not provided.
+     */
+    timeout?: number;
+  }): Promise<Disposable>;
+
+  /**
+   * Configures overlay behavior.
+   * @param options
+   */
+  configure(options?: {
+    /**
+     * Delay in milliseconds between actions when overlay is active.
+     */
+    actionDelay?: number;
+
+    /**
+     * CSS style string applied to the action title element displayed during actions.
+     */
+    actionStyle?: string;
+
+    /**
+     * CSS style string applied to the locator highlight element displayed during actions.
+     */
+    locatorStyle?: string;
+  }): Promise<void>;
+
+  /**
+   * Hides all overlays without removing them. Overlays can be shown again with
+   * [overlay.show()](https://playwright.dev/docs/api/class-overlay#overlay-show).
+   */
+  hide(): Promise<void>;
+
+  /**
+   * Shows previously hidden overlays.
+   */
+  show(): Promise<void>;
+}
+
 /**
  * This object can be used to launch or connect to Chromium, returning instances of
  * [Browser](https://playwright.dev/docs/api/class-browser).
@@ -22049,17 +22061,6 @@ export interface Video {
    * @param options
    */
   start(options?: {
-    /**
-     * If specified, enables visual annotations on interacted elements during video recording. Interacted elements are
-     * highlighted with a semi-transparent blue box and click points are shown as red circles.
-     */
-    annotate?: {
-      /**
-       * How long each annotation is displayed in milliseconds. Defaults to `500`.
-       */
-      delay?: number;
-    };
-
     /**
      * Path where the video should be saved when the recording is stopped.
      */
@@ -22965,16 +22966,6 @@ export interface BrowserContextOptions {
        */
       height: number;
     };
-
-    /**
-     * If specified, enables visual annotations on interacted elements during video recording.
-     */
-    annotate?: {
-      /**
-       * How long each annotation is displayed in milliseconds. Defaults to `500`.
-       */
-      delay?: number;
-    };
   };
 
   /**
diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts
index e8736b667735b..a6b1c3be6fcf6 100644
--- a/packages/playwright/src/common/testType.ts
+++ b/packages/playwright/src/common/testType.ts
@@ -265,6 +265,7 @@ export class TestTypeImpl {
     const testInfo = currentTestInfo();
     if (!testInfo)
       throw new Error(`test.step() can only be called from a test`);
+    await testInfo._onUserStepBegin?.(title);
     const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box });
     return await currentZone().with('stepZone', step).run(async () => {
       try {
@@ -287,6 +288,8 @@ export class TestTypeImpl {
       } catch (error) {
         step.complete({ error });
         throw error;
+      } finally {
+        await testInfo._onUserStepEnd?.();
       }
     });
   }
diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts
index 2fa3d1cf1688a..47ef15d93ab17 100644
--- a/packages/playwright/src/index.ts
+++ b/packages/playwright/src/index.ts
@@ -376,7 +376,6 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
         recordVideo: {
           dir: tracing().artifactsDir(),
           size: typeof video === 'string' ? undefined : video.size,
-          annotate: typeof video === 'string' ? undefined : video.annotate,
         }
       } : {};
       const context = await browser.newContext({ ...videoOptions, ...options }) as BrowserContextImpl;
@@ -438,14 +437,16 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
     await use(reuse);
   }, { scope: 'worker',  title: 'context', box: true }],
 
-  context: async ({ browser, _reuseContext, _contextFactory }, use, testInfoPublic) => {
+  context: async ({ browser, video, _reuseContext, _contextFactory }, use, testInfoPublic) => {
     const browserImpl = browser as BrowserImpl;
     const testInfo = testInfoPublic as TestInfoImpl;
+    const annotate = typeof video === 'string' ? undefined : video.annotate;
     attachConnectedHeaderIfNeeded(testInfo, browserImpl);
     if (!_reuseContext) {
       const { context, close } = await _contextFactory();
       testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context);
       await runDaemonForContext(testInfo, context);
+      await installScreencastTitleUpdater(testInfo, context, annotate);
       await use(context);
       await close();
       return;
@@ -454,6 +455,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
     const context = await browserImpl._wrapApiCall(() => browserImpl._newContextForReuse(), { internal: true });
     testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context);
     await runDaemonForContext(testInfo, context);
+    await installScreencastTitleUpdater(testInfo, context, annotate);
     await use(context);
     const closeReason = testInfo.status === 'timedOut' ? 'Test timeout of ' + testInfo.timeout + 'ms exceeded.' : 'Test ended.';
     await browserImpl._wrapApiCall(() => browserImpl._disconnectFromReusedContext(closeReason), { internal: true });
@@ -467,8 +469,10 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
 
     // First time we are reusing the context, we should create the page.
     let [page] = context.pages();
-    if (!page)
+    if (!page) {
       page = await context.newPage();
+      await page.overlay.add(test.info().titlePath.join(' › '));
+    }
     await use(page);
   },
 
@@ -797,6 +801,61 @@ class ArtifactsRecorder {
   }
 }
 
+async function installScreencastTitleUpdater(testInfo: TestInfoImpl, context: BrowserContext, annotate?: { action?: { delay?: number, actionStyle?: string, locatorStyle?: string }, test?: { level?: 'file' | 'title' | 'step' } }) {
+  if (!annotate?.action && !annotate?.test)
+    return;
+
+  if (annotate.action) {
+    const configureOptions = {
+      actionDelay: annotate.action.delay,
+      actionStyle: annotate.action.actionStyle,
+      locatorStyle: annotate.action.locatorStyle
+    };
+    for (const page of context.pages())
+      await page.overlay.configure(configureOptions);
+    context.on('page', page => {
+      page.overlay.configure(configureOptions).catch(() => {});
+    });
+  }
+
+  if (!annotate?.test)
+    return;
+
+  const testTitle = annotate.test.level === 'file' ? [testInfo.titlePath[0]] : testInfo.titlePath;
+  const stepStack: string[] = [];
+  const overlays = new Map<Page, { dispose(): Promise<void> }>();
+
+  const updateOverlay = async () => {
+    const parts = annotate.test!.level === 'step' ? [...testTitle, ...stepStack] : testTitle;
+    const html = createTestOverlay(parts);
+    for (const page of context.pages()) {
+      await overlays.get(page)?.dispose();
+      overlays.delete(page);
+      const disposable = await page.overlay.add(html);
+      overlays.set(page, disposable);
+    }
+  };
+  testInfo._onUserStepBegin = async title => {
+    stepStack.push(title);
+    await updateOverlay();
+  };
+  testInfo._onUserStepEnd = async () => {
+    stepStack.pop();
+    await updateOverlay();
+  };
+
+  context.on('page', async () => {
+    void updateOverlay();
+  });
+  await updateOverlay();
+}
+
+function createTestOverlay(parts: string[]) {
+  return `<div style="white-space: nowrap; font-size: 13px; padding: 3px 6px; background: rgba(0,0,0,0.5); color: white; border-radius: 4px; position: absolute; top: 3px; left: 3px;">
+    ${parts.map(p => `<div>${p}</div>`).join('')}
+  </div>`;
+}
+
 function renderTitle(type: string, method: string, params: Record<string, string> | undefined, title?: string) {
   const prefix = renderTitleForCall({ title, type, method, params });
   let selector;
diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts
index cd2f2b880c4eb..366eaa5971567 100644
--- a/packages/playwright/src/worker/testInfo.ts
+++ b/packages/playwright/src/worker/testInfo.ts
@@ -98,6 +98,8 @@ export class TestInfoImpl implements TestInfo {
   private readonly _stepMap = new Map<string, TestStepInternal>();
   _onDidFinishTestFunctionCallbacks = new Set<() => Promise<void>>();
   _onCustomMessageCallback?: (data: any) => Promise<any>;
+  _onUserStepBegin?: (title: string) => Promise<void>;
+  _onUserStepEnd?: () => Promise<void>;
   _hasNonRetriableError = false;
   _hasUnhandledError = false;
   _allowSkips = false;
diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts
index 43f255ee0f574..9fa429d5ed752 100644
--- a/packages/playwright/types/test.d.ts
+++ b/packages/playwright/types/test.d.ts
@@ -6933,8 +6933,9 @@ export interface PlaywrightWorkerOptions {
    * down to fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual
    * picture of each page will be scaled down if necessary to fit the specified size.
    *
-   * To annotate actions in the video with element highlights and action title subtitles, pass `annotate` with an
-   * optional `delay` in milliseconds (defaults to `500`).
+   * To annotate actions in the video, pass `annotate` with `action` and/or `test` sub-options. The `action` option
+   * controls visual highlights on interacted elements with an optional `delay` in milliseconds (defaults to `500`). The
+   * `test` option controls which test information is displayed as a status overlay.
    *
    * **Usage**
    *
@@ -6951,7 +6952,7 @@ export interface PlaywrightWorkerOptions {
    *
    * Learn more about [recording video](https://playwright.dev/docs/test-use-options#recording-options).
    */
-  video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, annotate?: { delay?: number } };
+  video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, annotate?: { action?: { delay?: number, actionStyle?: string, locatorStyle?: string }, test?: { level?: 'file' | 'title' | 'step' } } };
 }
 
 export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';
diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts
index 666b023eecd26..35a5c35dac09b 100644
--- a/packages/protocol/src/channels.d.ts
+++ b/packages/protocol/src/channels.d.ts
@@ -1027,9 +1027,6 @@ export type BrowserTypeLaunchPersistentContextParams = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   serviceWorkers?: 'allow' | 'block',
@@ -1113,9 +1110,6 @@ export type BrowserTypeLaunchPersistentContextOptions = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   serviceWorkers?: 'allow' | 'block',
@@ -1267,9 +1261,6 @@ export type BrowserNewContextParams = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   serviceWorkers?: 'allow' | 'block',
@@ -1338,9 +1329,6 @@ export type BrowserNewContextOptions = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   serviceWorkers?: 'allow' | 'block',
@@ -1412,9 +1400,6 @@ export type BrowserNewContextForReuseParams = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   serviceWorkers?: 'allow' | 'block',
@@ -1483,9 +1468,6 @@ export type BrowserNewContextForReuseOptions = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   serviceWorkers?: 'allow' | 'block',
@@ -1621,9 +1603,6 @@ export type BrowserContextInitializer = {
         width: number,
         height: number,
       },
-      annotate?: {
-        delay?: number,
-      },
     },
     strictSelectors?: boolean,
     serviceWorkers?: 'allow' | 'block',
@@ -2168,6 +2147,11 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
   bringToFront(params?: PageBringToFrontParams, progress?: Progress): Promise<PageBringToFrontResult>;
   pickLocator(params?: PagePickLocatorParams, progress?: Progress): Promise<PagePickLocatorResult>;
   cancelPickLocator(params?: PageCancelPickLocatorParams, progress?: Progress): Promise<PageCancelPickLocatorResult>;
+  overlayAdd(params: PageOverlayAddParams, progress?: Progress): Promise<PageOverlayAddResult>;
+  overlayRemove(params: PageOverlayRemoveParams, progress?: Progress): Promise<PageOverlayRemoveResult>;
+  overlayHide(params?: PageOverlayHideParams, progress?: Progress): Promise<PageOverlayHideResult>;
+  overlayShow(params?: PageOverlayShowParams, progress?: Progress): Promise<PageOverlayShowResult>;
+  overlayConfigure(params: PageOverlayConfigureParams, progress?: Progress): Promise<PageOverlayConfigureResult>;
   startScreencast(params: PageStartScreencastParams, progress?: Progress): Promise<PageStartScreencastResult>;
   stopScreencast(params?: PageStopScreencastParams, progress?: Progress): Promise<PageStopScreencastResult>;
   videoStart(params: PageVideoStartParams, progress?: Progress): Promise<PageVideoStartResult>;
@@ -2687,23 +2671,51 @@ export type PagePickLocatorResult = {
 export type PageCancelPickLocatorParams = {};
 export type PageCancelPickLocatorOptions = {};
 export type PageCancelPickLocatorResult = void;
+export type PageOverlayAddParams = {
+  html: string,
+  timeout?: number,
+};
+export type PageOverlayAddOptions = {
+  timeout?: number,
+};
+export type PageOverlayAddResult = {
+  id: string,
+};
+export type PageOverlayRemoveParams = {
+  id: string,
+};
+export type PageOverlayRemoveOptions = {
+
+};
+export type PageOverlayRemoveResult = void;
+export type PageOverlayHideParams = {};
+export type PageOverlayHideOptions = {};
+export type PageOverlayHideResult = void;
+export type PageOverlayShowParams = {};
+export type PageOverlayShowOptions = {};
+export type PageOverlayShowResult = void;
+export type PageOverlayConfigureParams = {
+  actionDelay?: number,
+  actionStyle?: string,
+  locatorStyle?: string,
+};
+export type PageOverlayConfigureOptions = {
+  actionDelay?: number,
+  actionStyle?: string,
+  locatorStyle?: string,
+};
+export type PageOverlayConfigureResult = void;
 export type PageStartScreencastParams = {
   preferredSize?: {
     width: number,
     height: number,
   },
-  annotate?: {
-    delay?: number,
-  },
 };
 export type PageStartScreencastOptions = {
   preferredSize?: {
     width: number,
     height: number,
   },
-  annotate?: {
-    delay?: number,
-  },
 };
 export type PageStartScreencastResult = void;
 export type PageStopScreencastParams = {};
@@ -2714,18 +2726,12 @@ export type PageVideoStartParams = {
     width: number,
     height: number,
   },
-  annotate?: {
-    delay?: number,
-  },
 };
 export type PageVideoStartOptions = {
   size?: {
     width: number,
     height: number,
   },
-  annotate?: {
-    delay?: number,
-  },
 };
 export type PageVideoStartResult = {
   artifact: ArtifactChannel,
@@ -4660,9 +4666,6 @@ export type ElectronLaunchParams = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   timezoneId?: string,
@@ -4699,9 +4702,6 @@ export type ElectronLaunchOptions = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   timezoneId?: string,
@@ -5089,9 +5089,6 @@ export type AndroidDeviceLaunchBrowserParams = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   serviceWorkers?: 'allow' | 'block',
@@ -5158,9 +5155,6 @@ export type AndroidDeviceLaunchBrowserOptions = {
       width: number,
       height: number,
     },
-    annotate?: {
-      delay?: number,
-    },
   },
   strictSelectors?: boolean,
   serviceWorkers?: 'allow' | 'block',
diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml
index 689e8ddbd420f..2e9b84fccf229 100644
--- a/packages/protocol/src/protocol.yml
+++ b/packages/protocol/src/protocol.yml
@@ -598,10 +598,6 @@ ContextOptions:
           properties:
             width: int
             height: int
-        annotate:
-          type: object?
-          properties:
-            delay: int?
     strictSelectors: boolean?
     serviceWorkers:
       type: enum?
@@ -2096,6 +2092,37 @@ Page:
       title: Cancel pick locator
       group: configuration
 
+    overlayAdd:
+      title: Add overlay
+      group: configuration
+      parameters:
+        html: string
+        timeout: int?
+      returns:
+        id: string
+
+    overlayRemove:
+      title: Remove overlay
+      group: configuration
+      parameters:
+        id: string
+
+    overlayHide:
+      title: Hide overlay
+      group: configuration
+
+    overlayShow:
+      title: Show overlay
+      group: configuration
+
+    overlayConfigure:
+      title: Configure overlay
+      group: configuration
+      parameters:
+        actionDelay: int?
+        actionStyle: string?
+        locatorStyle: string?
+
     startScreencast:
       title: Start screencast
       group: configuration
@@ -2105,10 +2132,6 @@ Page:
           properties:
             width: int
             height: int
-        annotate:
-          type: object?
-          properties:
-            delay: int?
 
     stopScreencast:
       title: Stop screencast
@@ -2123,10 +2146,6 @@ Page:
           properties:
             width: int
             height: int
-        annotate:
-          type: object?
-          properties:
-            delay: int?
       returns:
         artifact: Artifact
 
@@ -4147,10 +4166,6 @@ Electron:
               properties:
                 width: int
                 height: int
-            annotate:
-              type: object?
-              properties:
-                delay: int?
         strictSelectors: boolean?
         timezoneId: string?
         tracesDir: string?
diff --git a/tests/library/overlay.spec.ts b/tests/library/overlay.spec.ts
new file mode 100644
index 0000000000000..8864bed4bff66
--- /dev/null
+++ b/tests/library/overlay.spec.ts
@@ -0,0 +1,210 @@
+/**
+ * Copyright Microsoft Corporation. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, browserTest as test } from '../config/browserTest';
+
+test.skip(({ mode }) => mode !== 'default', 'Overlay uses an open shadow root only in default mode');
+
+test('should add and remove overlay', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  const disposable = await page.overlay.add('<div id="my-overlay">Hello Overlay</div>');
+  await expect(page.locator('x-pw-user-overlays')).toBeVisible();
+  await expect(page.locator('.x-pw-user-overlay')).toHaveCount(1);
+  await expect(page.locator('#my-overlay')).toHaveText('Hello Overlay');
+
+  await disposable.dispose();
+  await expect(page.locator('.x-pw-user-overlay')).toHaveCount(0);
+
+  await context.close();
+});
+
+test('should add multiple overlays', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  const d1 = await page.overlay.add('<div id="overlay-1">First</div>');
+  const d2 = await page.overlay.add('<div id="overlay-2">Second</div>');
+  await expect(page.locator('.x-pw-user-overlay')).toHaveCount(2);
+  await expect(page.locator('#overlay-1')).toHaveText('First');
+  await expect(page.locator('#overlay-2')).toHaveText('Second');
+
+  await d1.dispose();
+  await expect(page.locator('.x-pw-user-overlay')).toHaveCount(1);
+  await expect(page.locator('#overlay-2')).toHaveText('Second');
+
+  await d2.dispose();
+  await expect(page.locator('.x-pw-user-overlay')).toHaveCount(0);
+
+  await context.close();
+});
+
+test('should hide and show overlays', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  await page.overlay.add('<div id="my-overlay">Visible</div>');
+  await expect(page.locator('x-pw-user-overlays')).toBeVisible();
+
+  await page.overlay.hide();
+  await expect(page.locator('x-pw-user-overlays')).toBeHidden();
+
+  await page.overlay.show();
+  await expect(page.locator('x-pw-user-overlays')).toBeVisible();
+  await expect(page.locator('#my-overlay')).toHaveText('Visible');
+
+  await context.close();
+});
+
+test('should survive navigation', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  await page.overlay.add('<div id="persistent">Survives Reload</div>');
+  await expect(page.locator('#persistent')).toHaveText('Survives Reload');
+
+  await page.goto(server.EMPTY_PAGE);
+  await expect(page.locator('#persistent')).toHaveText('Survives Reload');
+
+  await page.reload();
+  await expect(page.locator('#persistent')).toHaveText('Survives Reload');
+
+  await context.close();
+});
+
+test('should remove overlay and not restore after navigation', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  const disposable = await page.overlay.add('<div id="temp">Temporary</div>');
+  await expect(page.locator('#temp')).toHaveText('Temporary');
+
+  await disposable.dispose();
+  await expect(page.locator('.x-pw-user-overlay')).toHaveCount(0);
+
+  await page.reload();
+  await expect(page.locator('.x-pw-user-overlay')).toHaveCount(0);
+
+  await context.close();
+});
+
+test('should sanitize scripts from overlay html', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  await page.overlay.add('<div id="safe">Safe</div><script>window.__injected = true</script>');
+  await expect(page.locator('#safe')).toHaveText('Safe');
+  expect(await page.evaluate(() => (window as any).__injected)).toBeUndefined();
+
+  await context.close();
+});
+
+test('should strip event handlers from overlay html', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  await page.overlay.add('<div id="clean" onclick="window.__clicked=true">Click me</div>');
+  await expect(page.locator('#clean')).toHaveText('Click me');
+  const hasOnclick = await page.locator('#clean').evaluate(el => el.hasAttribute('onclick'));
+  expect(hasOnclick).toBe(false);
+
+  await context.close();
+});
+
+test('should show action highlight and title on click', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+  await page.setContent('<button id="btn">Click me</button>');
+
+  await page.overlay.configure({ actionDelay: 5000 });
+  const clickPromise = page.locator('#btn').click();
+
+  await expect(page.locator('x-pw-highlight')).toBeVisible();
+  await expect(page.locator('x-pw-title')).toBeVisible();
+  await expect(page.locator('x-pw-title')).toHaveText(/click/i);
+
+  await clickPromise;
+  await context.close();
+});
+
+test('should apply locatorStyle to highlight element', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+  await page.setContent('<button id="btn">Click me</button>');
+
+  await page.overlay.configure({ actionDelay: 5000, locatorStyle: 'border: 5px solid red' });
+  const clickPromise = page.locator('#btn').click();
+
+  const highlight = page.locator('x-pw-highlight');
+  await expect(highlight).toBeVisible();
+  const border = await highlight.evaluate(el => el.style.border);
+  expect(border).toBe('5px solid red');
+
+  await clickPromise;
+  await context.close();
+});
+
+test('should apply actionStyle to title element', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+  await page.setContent('<button id="btn">Click me</button>');
+
+  await page.overlay.configure({ actionDelay: 5000, actionStyle: 'font-size: 42px' });
+  const clickPromise = page.locator('#btn').click();
+
+  const title = page.locator('x-pw-title');
+  await expect(title).toBeVisible();
+  const fontSize = await title.evaluate(el => el.style.fontSize);
+  expect(fontSize).toBe('42px');
+
+  await clickPromise;
+  await context.close();
+});
+
+test('should auto-remove overlay after timeout', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  await page.overlay.add('<div id="timed">Temporary</div>', { timeout: 1 });
+  await expect(page.locator('.x-pw-user-overlay')).toHaveCount(0);
+
+  await context.close();
+});
+
+test('should allow styles in overlay html', async ({ browser, server }) => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await page.goto(server.EMPTY_PAGE);
+
+  await page.overlay.add('<div id="styled" style="color: red; font-size: 20px;">Styled</div>');
+  await expect(page.locator('#styled')).toHaveText('Styled');
+  const color = await page.locator('#styled').evaluate(el => getComputedStyle(el).color);
+  expect(color).toBe('rgb(255, 0, 0)');
+
+  await context.close();
+});
diff --git a/tests/mcp/cli-run-code.spec.ts b/tests/mcp/cli-run-code.spec.ts
new file mode 100644
index 0000000000000..f14eff4b0a1dc
--- /dev/null
+++ b/tests/mcp/cli-run-code.spec.ts
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'fs';
+
+import { test, expect } from './cli-fixtures';
+
+test('run-code inline', async ({ cli, server }) => {
+  server.setContent('/', `
+    <button onclick="document.title = 'clicked'">Submit</button>
+  `, 'text/html');
+  await cli('open', server.PREFIX);
+  const { output } = await cli('run-code', 'async (page) => { await page.getByRole("button", { name: "Submit" }).click(); return await page.title(); }');
+  expect(output).toContain('clicked');
+});
+
+test('run-code from file', async ({ cli, server }) => {
+  server.setContent('/', `
+    <button onclick="document.title = 'from-file'">Submit</button>
+  `, 'text/html');
+  await cli('open', server.PREFIX);
+
+  const code = 'async (page) => { await page.getByRole("button", { name: "Submit" }).click(); return await page.title(); }';
+  await fs.promises.writeFile(test.info().outputPath('script.js'), code);
+
+  const { output } = await cli('run-code', '--filename=script.js');
+  expect(output).toContain('from-file');
+});
+
+test('run-code from file with template literals', async ({ cli, server }) => {
+  server.setContent('/', `
+    <button onclick="document.title = 'templated'">Submit</button>
+  `, 'text/html');
+  await cli('open', server.PREFIX);
+
+  const code = [
+    'async (page) => {',
+    '  await page.getByRole("button", { name: "Submit" }).click();',
+    '  const title = await page.title();',
+    '  return `result: ${title}`;',
+    '}',
+  ].join('\n');
+  await fs.promises.writeFile(test.info().outputPath('template-script.js'), code);
+
+  const { output } = await cli('run-code', '--filename=template-script.js');
+  expect(output).toContain('result: templated');
+});
diff --git a/tests/mcp/run-code.spec.ts b/tests/mcp/run-code.spec.ts
index cf17ad6652521..4341e1c53d1ee 100644
--- a/tests/mcp/run-code.spec.ts
+++ b/tests/mcp/run-code.spec.ts
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+import fs from 'fs';
+
 import { test, expect, parseResponse, consoleEntries } from './fixtures';
 
 test('browser_run_code', async ({ client, server }) => {
@@ -105,3 +107,45 @@ test('browser_run_code return value', async ({ client, server }) => {
   const content = await consoleEntries(response);
   expect(content).toContain('[LOG] Submit');
 });
+
+test('browser_run_code with filename', async ({ client, server }) => {
+  server.setContent('/', `
+    <button onclick="console.log('Clicked')">Click</button>
+  `, 'text/html');
+  await client.callTool({
+    name: 'browser_navigate',
+    arguments: { url: server.PREFIX },
+  });
+
+  const code = 'async (page) => {\n  await page.getByRole("button", { name: "Click" }).click();\n}';
+  const filePath = test.info().outputPath('test-code.js');
+  await fs.promises.writeFile(filePath, code);
+
+  const response = parseResponse(await client.callTool({
+    name: 'browser_run_code',
+    arguments: { filename: 'test-code.js' },
+  }));
+  const content = await consoleEntries(response);
+  expect(content).toContain('[LOG] Clicked');
+});
+
+test('browser_run_code with filename containing template literals', async ({ client, server }) => {
+  server.setContent('/', `
+    <button onclick="console.log('Done')">Submit</button>
+  `, 'text/html');
+  await client.callTool({
+    name: 'browser_navigate',
+    arguments: { url: server.PREFIX },
+  });
+
+  const code = 'async (page) => {\n  const title = `Page: ${await page.title()}`;\n  await page.getByRole("button", { name: "Submit" }).click();\n  return title;\n}';
+  const filePath = test.info().outputPath('template-code.js');
+  await fs.promises.writeFile(filePath, code);
+
+  const response = parseResponse(await client.callTool({
+    name: 'browser_run_code',
+    arguments: { filename: 'template-code.js' },
+  }));
+  const content = await consoleEntries(response);
+  expect(content).toContain('[LOG] Done');
+});
diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts
index ed37bba3ac083..66ddbd247017c 100644
--- a/tests/playwright-test/test-step.spec.ts
+++ b/tests/playwright-test/test-step.spec.ts
@@ -557,10 +557,10 @@ pw:api    |    Create context
 fixture   |  Fixture "page"
 pw:api    |    Create page
 test.step |grand @ a.test.ts:20
-test.step |  parent1 @ a.test.ts:22
+test.step |  parent1 @ a.test.ts:21
 test.step |    child1 @ a.test.ts:23
 pw:api    |      Click locator('body') @ a.test.ts:24
-test.step |  parent2 @ a.test.ts:27
+test.step |  parent2 @ a.test.ts:21
 test.step |    child2 @ a.test.ts:28
 expect    |      Expect "toBeVisible" main element @ a.test.ts:29
 hook      |After Hooks
@@ -785,16 +785,16 @@ hook      |Before Hooks
 test.step |boxed step @ a.test.ts:3
 test.step |↪ error: Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:4
 test.step |    at a.test.ts:4:27
-test.step |    at a.test.ts:3:26
+test.step |    at a.test.ts:3:15
 expect    |  Expect "toBe" @ a.test.ts:4
 expect    |  ↪ error: Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:4
 expect    |      at a.test.ts:4:27
-expect    |      at a.test.ts:3:26
+expect    |      at a.test.ts:3:15
 hook      |After Hooks
 hook      |Worker Cleanup
           |Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:4
           |    at a.test.ts:4:27
-          |    at a.test.ts:3:26
+          |    at a.test.ts:3:15
 `);
 });
 
@@ -820,14 +820,14 @@ test('should step w/ box', async ({ runInlineTest }) => {
 hook      |Before Hooks
 test.step |boxed step @ a.test.ts:8
 test.step |↪ error: Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:8
-test.step |    at a.test.ts:8:21
+test.step |    at a.test.ts:8:15
 expect    |  Expect "toBe" @ a.test.ts:5
 expect    |  ↪ error: Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:8
-expect    |      at a.test.ts:8:21
+expect    |      at a.test.ts:8:15
 hook      |After Hooks
 hook      |Worker Cleanup
           |Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:8
-          |    at a.test.ts:8:21
+          |    at a.test.ts:8:15
 `);
 });
 
@@ -853,14 +853,14 @@ test('should soft step w/ box', async ({ runInlineTest }) => {
 hook      |Before Hooks
 test.step |boxed step @ a.test.ts:8
 test.step |↪ error: Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:8
-test.step |    at a.test.ts:8:21
+test.step |    at a.test.ts:8:15
 expect    |  Expect "soft toBe" @ a.test.ts:5
 expect    |  ↪ error: Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:8
-expect    |      at a.test.ts:8:21
+expect    |      at a.test.ts:8:15
 hook      |After Hooks
 hook      |Worker Cleanup
           |Error: expect(received).toBe(expected) // Object.is equality @ a.test.ts:8
-          |    at a.test.ts:8:21
+          |    at a.test.ts:8:15
 `);
 });
 
diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts
index fa86e110834b1..aad0cc930652b 100644
--- a/utils/generate_types/overrides-test.d.ts
+++ b/utils/generate_types/overrides-test.d.ts
@@ -259,7 +259,7 @@ export interface PlaywrightWorkerOptions {
   connectOptions: ConnectOptions | undefined;
   screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick<PageScreenshotOptions, 'fullPage' | 'omitBackground'>;
   trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean, screenshots?: boolean, sources?: boolean, attachments?: boolean };
-  video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, annotate?: { delay?: number } };
+  video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, annotate?: { action?: { delay?: number, actionStyle?: string, locatorStyle?: string }, test?: { level?: 'file' | 'title' | 'step' } } };
 }
 
 export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';
