[go: nahoru, domu]

[Recorder] Add Recorder implementation

DISABLE_THIRD_PARTY_CHECK=ignore

Bug: 1414773
Change-Id: I5357439ad5af634829dbe275dad2e5aa270036b0
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/4498865
Commit-Queue: Alex Rudenko <alexrudenko@chromium.org>
Reviewed-by: Nikolay Vitkov <nvitkov@chromium.org>
diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni
index 17f1a0c..84fb157 100644
--- a/config/gni/devtools_grd_files.gni
+++ b/config/gni/devtools_grd_files.gni
@@ -542,6 +542,15 @@
   "front_end/panels/webauthn/webauthn-meta.js",
   "front_end/panels/webauthn/webauthn.js",
   "front_end/services/puppeteer/puppeteer.js",
+  "front_end/panels/recorder/images/close_icon.svg",
+  "front_end/panels/recorder/images/delete_icon.svg",
+  "front_end/panels/recorder/images/edit_icon.svg",
+  "front_end/panels/recorder/images/export_icon.svg",
+  "front_end/panels/recorder/images/import_icon.svg",
+  "front_end/panels/recorder/images/play_icon.svg",
+  "front_end/panels/recorder/images/question_icon.svg",
+  "front_end/panels/recorder/images/select-arrow-icon.svg",
+  "front_end/panels/recorder/images/settings_cog_icon.svg",
   "front_end/services/window_bounds/window_bounds.js",
   "front_end/third_party/acorn/acorn.js",
   "front_end/third_party/chromium/client-variations/client-variations.js",
@@ -627,9 +636,114 @@
   "front_end/ui/legacy/utils/utils.js",
   "front_end/ui/lit-html/lit-html.js",
   "front_end/worker_app.html",
+  "front_end/third_party/puppeteer-replay/puppeteer-replay.js",
+  "front_end/services/tracing/tracing.js",
+  "front_end/panels/recorder/components/components.js",
+  "front_end/panels/recorder/controllers/controllers.js",
+  "front_end/panels/recorder/converters/converters.js",
+  "front_end/panels/recorder/extensions/extensions.js",
+  "front_end/panels/recorder/images/abort_icon.svg",
+  "front_end/panels/recorder/images/advance_icon.svg",
+  "front_end/panels/recorder/images/extension_icon.svg",
+  "front_end/panels/recorder/images/feedback_icon.svg",
+  "front_end/panels/recorder/images/minus_icon.svg",
+  "front_end/panels/recorder/images/more_icon.svg",
+  "front_end/panels/recorder/images/path_icon.svg",
+  "front_end/panels/recorder/images/plus_icon.svg",
+  "front_end/panels/recorder/images/record_icon.svg",
+  "front_end/panels/recorder/images/select_icon.svg",
+  "front_end/panels/recorder/images/step_icon.svg",
+  "front_end/panels/recorder/images/throttling_icon.svg",
+  "front_end/panels/recorder/injected/injected.generated.js",
+  "front_end/panels/recorder/injected/injected.js",
+  "front_end/panels/recorder/models/models.js",
+  "front_end/panels/recorder/recorder-actions.js",
+  "front_end/panels/recorder/recorder-meta.js",
+  "front_end/panels/recorder/recorder.js",
+  "front_end/panels/recorder/util/util.js",
+    "front_end/ui/components/dialogs/dialogs.js",
+  "front_end/ui/components/menus/menus.js",
 ]
 
 grd_files_debug_sources = [
+  "front_end/services/tracing/PerformanceTracing.js",
+  "front_end/third_party/puppeteer-replay/package/lib/main.js",
+  "front_end/panels/recorder/RecorderController.js",
+  "front_end/panels/recorder/RecorderEvents.js",
+  "front_end/panels/recorder/RecorderPanel.js",
+  "front_end/panels/recorder/components/ControlButton.js",
+  "front_end/panels/recorder/components/CreateRecordingView.js",
+  "front_end/panels/recorder/components/ExtensionView.js",
+  "front_end/panels/recorder/components/RecorderInput.js",
+  "front_end/panels/recorder/components/RecordingListView.js",
+  "front_end/panels/recorder/components/RecordingView.js",
+  "front_end/panels/recorder/components/ReplayButton.js",
+  "front_end/panels/recorder/components/SelectButton.js",
+  "front_end/panels/recorder/components/SplitView.js",
+  "front_end/panels/recorder/components/StartView.js",
+  "front_end/panels/recorder/components/StepEditor.js",
+  "front_end/panels/recorder/components/StepView.js",
+  "front_end/panels/recorder/components/TimelineSection.js",
+  "front_end/panels/recorder/components/controlButton.css.js",
+  "front_end/panels/recorder/components/createRecordingView.css.js",
+  "front_end/panels/recorder/components/extensionView.css.js",
+  "front_end/panels/recorder/components/recorderInput.css.js",
+  "front_end/panels/recorder/components/recordingListView.css.js",
+  "front_end/panels/recorder/components/recordingView.css.js",
+  "front_end/panels/recorder/components/selectButton.css.js",
+  "front_end/panels/recorder/components/startView.css.js",
+  "front_end/panels/recorder/components/stepEditor.css.js",
+  "front_end/panels/recorder/components/stepView.css.js",
+  "front_end/panels/recorder/components/timelineSection.css.js",
+  "front_end/panels/recorder/components/util.js",
+  "front_end/panels/recorder/controllers/SelectorPicker.js",
+  "front_end/panels/recorder/converters/Converter.js",
+  "front_end/panels/recorder/converters/ExtensionConverter.js",
+  "front_end/panels/recorder/converters/JSONConverter.js",
+  "front_end/panels/recorder/converters/LighthouseConverter.js",
+  "front_end/panels/recorder/converters/PuppeteerConverter.js",
+  "front_end/panels/recorder/converters/PuppeteerReplayConverter.js",
+  "front_end/panels/recorder/extensions/ExtensionManager.js",
+  "front_end/panels/recorder/injected/Logger.js",
+  "front_end/panels/recorder/injected/MonotonicArray.js",
+  "front_end/panels/recorder/injected/RecordingClient.js",
+  "front_end/panels/recorder/injected/SelectorComputer.js",
+  "front_end/panels/recorder/injected/SelectorPicker.js",
+  "front_end/panels/recorder/injected/Step.js",
+  "front_end/panels/recorder/injected/selectors/ARIASelector.js",
+  "front_end/panels/recorder/injected/selectors/CSSSelector.js",
+  "front_end/panels/recorder/injected/selectors/PierceSelector.js",
+  "front_end/panels/recorder/injected/selectors/Selector.js",
+  "front_end/panels/recorder/injected/selectors/TextSelector.js",
+  "front_end/panels/recorder/injected/selectors/XPath.js",
+  "front_end/panels/recorder/injected/util.js",
+  "front_end/panels/recorder/models/ConverterIds.js",
+  "front_end/panels/recorder/models/RecorderSettings.js",
+  "front_end/panels/recorder/models/RecorderShortcutHelper.js",
+  "front_end/panels/recorder/models/RecordingPlayer.js",
+  "front_end/panels/recorder/models/RecordingSession.js",
+  "front_end/panels/recorder/models/RecordingSettings.js",
+  "front_end/panels/recorder/models/RecordingStorage.js",
+  "front_end/panels/recorder/models/SDKUtils.js",
+  "front_end/panels/recorder/models/Schema.js",
+  "front_end/panels/recorder/models/SchemaUtils.js",
+  "front_end/panels/recorder/models/ScreenshotStorage.js",
+  "front_end/panels/recorder/models/ScreenshotUtils.js",
+  "front_end/panels/recorder/models/Section.js",
+  "front_end/panels/recorder/models/Tooltip.js",
+  "front_end/panels/recorder/recorderController.css.js",
+  "front_end/panels/recorder/util/SharedObject.js",
+  "front_end/ui/components/dialogs/Dialog.js",
+  "front_end/ui/components/dialogs/ShortcutDialog.js",
+  "front_end/ui/components/dialogs/dialog.css.js",
+  "front_end/ui/components/dialogs/shortcutDialog.css.js",
+  "front_end/ui/components/menus/Menu.js",
+  "front_end/ui/components/menus/SelectMenu.js",
+  "front_end/ui/components/menus/menu.css.js",
+  "front_end/ui/components/menus/menuGroup.css.js",
+  "front_end/ui/components/menus/menuItem.css.js",
+  "front_end/ui/components/menus/selectMenu.css.js",
+  "front_end/ui/components/menus/selectMenuButton.css.js",
   "front_end/core/common/App.js",
   "front_end/core/common/AppProvider.js",
   "front_end/core/common/Base64.js",
diff --git a/front_end/.eslintrc.js b/front_end/.eslintrc.js
index 748e141..d530079 100644
--- a/front_end/.eslintrc.js
+++ b/front_end/.eslintrc.js
@@ -150,6 +150,16 @@
       'rules': {
         'rulesdir/use_private_class_members': 2,
       }
+    },
+    // TODO(crbug/1402569): Remove once LitElement is fully adopted.
+    {
+      'files': ['panels/recorder/**/*.ts'],
+      'rules': {
+        'rulesdir/check_component_naming': 0,
+        'rulesdir/ban_literal_devtools_component_tag_names': 0,
+        // TODO(crbug/1402569): Reenable once https://github.com/microsoft/TypeScript/issues/48885 is closed.
+        'rulesdir/use_private_class_members': 0,
+      }
     }
   ]
 };
diff --git a/front_end/entrypoints/devtools_app/BUILD.gn b/front_end/entrypoints/devtools_app/BUILD.gn
index 444670b..45d44ac 100644
--- a/front_end/entrypoints/devtools_app/BUILD.gn
+++ b/front_end/entrypoints/devtools_app/BUILD.gn
@@ -25,6 +25,7 @@
     "../../panels/mobile_throttling:meta",
     "../../panels/network:meta",
     "../../panels/performance_monitor:meta",
+    "../../panels/recorder:meta",
     "../../panels/security:meta",
     "../../panels/sensors:meta",
     "../../panels/timeline:meta",
diff --git a/front_end/entrypoints/devtools_app/devtools_app.ts b/front_end/entrypoints/devtools_app/devtools_app.ts
index 2982e4e..18e1bfc 100644
--- a/front_end/entrypoints/devtools_app/devtools_app.ts
+++ b/front_end/entrypoints/devtools_app/devtools_app.ts
@@ -24,6 +24,7 @@
 import '../../panels/web_audio/web_audio-meta.js';
 import '../../panels/webauthn/webauthn-meta.js';
 import '../../panels/layer_viewer/layer_viewer-meta.js';
+import '../../panels/recorder/recorder-meta.js';
 
 import * as Root from '../../core/root/root.js';
 import * as Main from '../main/main.js';
diff --git a/front_end/models/bindings/BUILD.gn b/front_end/models/bindings/BUILD.gn
index 7f322af..fb56aab 100644
--- a/front_end/models/bindings/BUILD.gn
+++ b/front_end/models/bindings/BUILD.gn
@@ -61,6 +61,7 @@
     "../../panels/network/*",
     "../../panels/profiler/*",
     "../../panels/sources/*",
+    "../../panels/recorder/*",
     "../persistence/*",
 
     # TODO(crbug.com/1202788): Remove invalid dependents
diff --git a/front_end/models/extensions/BUILD.gn b/front_end/models/extensions/BUILD.gn
index 4404ef0..10833de 100644
--- a/front_end/models/extensions/BUILD.gn
+++ b/front_end/models/extensions/BUILD.gn
@@ -50,6 +50,7 @@
     "../../panels/elements/*",
     "../../panels/sources/*",
     "../../panels/timeline/*",
+    "../../panels/recorder/*",
   ]
 
   visibility += devtools_models_visibility
diff --git a/front_end/models/persistence/BUILD.gn b/front_end/models/persistence/BUILD.gn
index ac4ea7a..718c729 100644
--- a/front_end/models/persistence/BUILD.gn
+++ b/front_end/models/persistence/BUILD.gn
@@ -62,6 +62,7 @@
     "../../panels/network/*",
     "../../panels/snippets/*",
     "../../panels/sources/*",
+    "../../panels/recorder/*",
     "../workspace_diff/*",
   ]
 
diff --git a/front_end/panels/elements/BUILD.gn b/front_end/panels/elements/BUILD.gn
index 3a73371..bc46ec8 100644
--- a/front_end/panels/elements/BUILD.gn
+++ b/front_end/panels/elements/BUILD.gn
@@ -123,6 +123,7 @@
     "../../ui/components/docs/elements_breadcrumbs/*",
     "../../ui/components/docs/flexbox_editor/*",
     "../../ui/components/docs/layout_pane/*",
+    "../../panels/recorder/*",
 
     # TODO(crbug.com/1202788): Remove invalid dependents
     "../network/*",
diff --git a/front_end/panels/emulation/BUILD.gn b/front_end/panels/emulation/BUILD.gn
index 20a07a4..a00e678 100644
--- a/front_end/panels/emulation/BUILD.gn
+++ b/front_end/panels/emulation/BUILD.gn
@@ -71,6 +71,7 @@
     "../elements/*",
     "../lighthouse/*",
     "../settings/emulation/*",
+    "../recorder/*",
   ]
 
   visibility += devtools_panels_visibility
diff --git a/front_end/panels/recorder/BUILD.gn b/front_end/panels/recorder/BUILD.gn
new file mode 100644
index 0000000..be77683
--- /dev/null
+++ b/front_end/panels/recorder/BUILD.gn
@@ -0,0 +1,89 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../scripts/build/ninja/devtools_module.gni")
+import("../../../scripts/build/ninja/generate_css.gni")
+
+generate_css("css_files") {
+  sources = [ "recorderController.css" ]
+}
+
+devtools_module("recorder") {
+  sources = [
+    "RecorderController.ts",
+    "RecorderEvents.ts",
+    "RecorderPanel.ts",
+  ]
+
+  deps = [
+    ":actions",
+    "../../core/common:bundle",
+    "../../core/platform:bundle",
+    "../../models/extensions:bundle",
+    "../../panels/emulation:bundle",
+    "../../panels/timeline:bundle",
+    "../../ui/components/icon_button:bundle",
+    "../../services/tracing:bundle",
+    "../../third_party/puppeteer-replay:bundle",
+    "../../ui/components/dialogs:bundle",
+    "../../ui/components/menus:bundle",
+    "./components:bundle",
+    "./controllers:bundle",
+    "./converters:bundle",
+    "./extensions:bundle",
+    "./models:bundle",
+  ]
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "recorder.ts"
+
+  deps = [
+    ":css_files",
+    ":recorder",
+  ]
+
+  visibility = [
+    ":*",
+    "../../../test/e2e/recorder:*",
+    "../../../test/unittests/front_end/models/recorder/*",
+    "../../../test/unittests/front_end/panels/recorder/*",
+    "../../entrypoints/main/*",
+    "../../panels/sources/*",
+  ]
+}
+
+devtools_entrypoint("meta") {
+  entrypoint = "recorder-meta.ts"
+
+  deps = [
+    ":actions",
+    ":bundle",
+    "../../core/common:bundle",
+    "../../core/host:bundle",
+    "../../core/i18n:bundle",
+    "../../core/sdk:bundle",
+    "../../models/bindings:bundle",
+    "../../ui/components/buttons:bundle",
+    "../../ui/legacy:bundle",
+  ]
+
+  visibility = [
+    "../..:*",
+    "../../entrypoints/devtools_app:*",
+    "../../entrypoints/inspector:*",
+  ]
+}
+
+devtools_entrypoint("actions") {
+  entrypoint = "recorder-actions.ts"
+
+  deps = []
+
+  visibility = [
+    ":*",
+    "./components:*",
+  ]
+}
diff --git a/front_end/panels/recorder/OWNERS b/front_end/panels/recorder/OWNERS
new file mode 100644
index 0000000..76f995d
--- /dev/null
+++ b/front_end/panels/recorder/OWNERS
@@ -0,0 +1 @@
+file://config/owner/RECORDER_OWNERS
diff --git a/front_end/panels/recorder/README.md b/front_end/panels/recorder/README.md
new file mode 100644
index 0000000..2f897c5
--- /dev/null
+++ b/front_end/panels/recorder/README.md
@@ -0,0 +1,30 @@
+# Recorder
+
+Recorder is a panel in DevTools that allows recording and replaying user actions.
+
+## Code overview
+
+- `components` folder contains lit-html components that implement Recorder-specific UI.
+- `images` folder contains Recorder-specific icons and images.
+- `models` folder contains the "business logic" part of Recorder wrapping DevTools SDK modules and Puppeteer to provide record/replay/import/export functionality. The name `models` is historical and, probably, a better name could be found: record/replay used to be implemented as a single SDK Model and was placed in the `models` folder in DevTools before ending up here.
+- `injected` folder holds the code that gets injected into the target page. This code is responsible for interpreting client side events and sending information about them to the `models`.
+- `RecorderPanel.ts` is a light wrapper around `RecorderController` that integrates with `UI.Panel.Panel`, a generic interface for a top-level panel.
+- `RecorderController.ts` is the main class that is responsible for rendering the panel and holds the entire panel state.
+
+## How it works
+
+In general, there are 3 important use cases which the Recorder panel covers:
+
+1. Browsing: covers the user navigation between recordings, viewing recording details, editing recordings, import/export.
+2. Recording: covers the scenario when a user creates a new recorder and starts recording actions. In this use case, most of the browsing functionality is disabled until the user stops the recordings. Here is what approximately happens when the user hits Start Recording:
+   1. A new `RecordingSession` is created.
+   2. The `RecordingSession` attaches itself to all the targets and install a binding.
+   3. Then it injects `injected` code into all the inspected targets.
+   4. `injected` code detects user actions on the target and sends them back to the `RecordingSession` using the binding.
+   5. `RecordingSession` interprets the events from the client and updates the current recording.
+3. Playback: covers the scenaraio when a user has a recording and hits the Replay button:
+   1. A new `RecordingPlayer` instance gets created.
+   2. The instance suspends of the DevTools interactions with the targets (i.e., `SDK.TargetManager.TargetManager.instance().suspendAllTargets();`) and creates a `PuppeteerConnection`.
+      1. If the playback includes measuring performance, additionally the tracing API is enabled to capture the performance trace.
+   3. The `PuppeteerConnection` is sending and receiving messages on top of the original DevTools connection (which could be a web socket or a binding connection).
+   4. Then `RecordingPlayer` interprets the steps and translates them into Puppeteer commands.
diff --git a/front_end/panels/recorder/RecorderController.ts b/front_end/panels/recorder/RecorderController.ts
new file mode 100644
index 0000000..9be1971
--- /dev/null
+++ b/front_end/panels/recorder/RecorderController.ts
@@ -0,0 +1,1384 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../core/common/common.js';
+import type * as Platform from '../../core/platform/platform.js';
+import * as Host from '../../core/host/host.js';
+import * as i18n from '../../core/i18n/i18n.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as Bindings from '../../models/bindings/bindings.js';
+import * as PublicExtensions from '../../models/extensions/extensions.js';
+import * as Emulation from '../../panels/emulation/emulation.js';
+import * as Timeline from '../../panels/timeline/timeline.js';
+import * as Buttons from '../../ui/components/buttons/buttons.js';
+import * as ComponentHelpers from '../../ui/components/helpers/helpers.js';
+import * as IconButton from '../../ui/components/icon_button/icon_button.js';
+import * as UI from '../../ui/legacy/legacy.js';
+import * as LitHtml from '../../ui/lit-html/lit-html.js';
+import * as Tracing from '../../services/tracing/tracing.js';
+import * as Menus from '../../ui/components/menus/menus.js';
+import * as Dialogs from '../../ui/components/dialogs/dialogs.js';
+import type * as Controllers from './controllers/controllers.js';
+import * as Components from './components/components.js';
+
+import {type AddBreakpointEvent, type RemoveBreakpointEvent} from './components/StepView.js';
+import * as Converters from './converters/converters.js';
+import * as Extensions from './extensions/extensions.js';
+
+import * as Models from './models/models.js';
+import recorderControllerStyles from './recorderController.css.js';
+import * as Events from './RecorderEvents.js';
+import * as Actions from './recorder-actions.js';
+
+const {html, Decorators, LitElement} = LitHtml;
+const {customElement, state} = Decorators;
+
+const UIStrings = {
+  /**
+   * @description The title of the button that leads to a page for creating a new recording.
+   */
+  createRecording: 'Create a new recording',
+  /**
+   * @description The title of the button that allows importing a recording.
+   */
+  importRecording: 'Import recording',
+  /**
+   * @description The title of the button that deletes the recording
+   */
+  deleteRecording: 'Delete recording',
+  /**
+   * @description The title of the button that continues the replay
+   */
+  continueReplay: 'Continue',
+  /**
+   * @description The title of the button that executes only one step in the replay
+   */
+  stepOverReplay: 'Execute one step',
+  /**
+   * @description The title of the button that opens a menu with various options of exporting a recording to file.
+   */
+  exportRecording: 'Export',
+  /**
+   * @description The title of shortcut for starting and stopping recording.
+   */
+  startStopRecording: 'Start/Stop recording',
+  /**
+   * @description The title of shortcut for replaying recording.
+   */
+  replayRecording: 'Replay recording',
+  /**
+   * @description The title of shortcut for copying a recording or selected step.
+   */
+  copyShortcut: 'Copy recording or selected step',
+  /**
+   * @description The title of shortcut for toggling code view.
+   */
+  toggleCode: 'Toggle code view',
+  /**
+   * @description The title of the menu group in the export menu of the Recorder
+   * panel that is followed by the list of built-in export formats.
+   */
+  export: 'Export',
+  /**
+   * @description The title of the menu group in the export menu of the Recorder
+   * panel that is followed by the list of export formats available via browser
+   * extensions.
+   */
+  exportViaExtensions: 'Export via extensions',
+  /**
+   * @description The title of the menu option that leads to a page that lists
+   * all browsers extensions available for Recorder.
+   */
+  getExtensions: 'Get extensions…',
+  /**
+   * @description The button label that leads to the feedback form for Recorder.
+   */
+  sendFeedback: 'Send feedback',
+};
+const str_ = i18n.i18n.registerUIStrings('panels/recorder/RecorderController.ts', UIStrings);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+const GET_EXTENSIONS_MENU_ITEM = 'get-extensions-link';
+const GET_EXTENSIONS_URL = 'https://goo.gle/recorder-extension-list' as Platform.DevToolsPath.UrlString;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-recorder-controller': RecorderController;
+  }
+
+  interface FileSystemWritableFileStream extends WritableStream {
+    write(data: unknown): Promise<void>;
+    close(): Promise<void>;
+  }
+
+  interface FileSystemHandle {
+    createWritable(): Promise<FileSystemWritableFileStream>;
+  }
+
+  interface Window {
+    showSaveFilePicker(opts: unknown): Promise<FileSystemHandle>;
+  }
+}
+
+interface StoredRecording {
+  storageName: string;
+  flow: Models.Schema.UserFlow;
+}
+
+interface SetCurrentRecordingOptions {
+  /**
+   * Whether to keep breakpoints in the recording.
+   */
+  keepBreakpoints: boolean;
+  /**
+   * Whether to upstream the recording to a recording session if it exists.
+   */
+  updateSession: boolean;
+}
+
+export const enum Pages {
+  StartPage = 'StartPage',
+  AllRecordingsPage = 'AllRecordingsPage',
+  CreateRecordingPage = 'CreateRecordingPage',
+  RecordingPage = 'RecordingPage',
+}
+
+const CONVERTER_ID_TO_METRIC: Record<string, Host.UserMetrics.RecordingExported|undefined> = {
+  [Models.ConverterIds.ConverterIds.JSON]: Host.UserMetrics.RecordingExported.ToJSON,
+  [Models.ConverterIds.ConverterIds.Replay]: Host.UserMetrics.RecordingExported.ToPuppeteerReplay,
+  [Models.ConverterIds.ConverterIds.Puppeteer]: Host.UserMetrics.RecordingExported.ToPuppeteer,
+  [Models.ConverterIds.ConverterIds.Lighthouse]: Host.UserMetrics.RecordingExported.ToLighthouse,
+};
+
+const plusIconUrl = new URL('./images/plus_icon.svg', import.meta.url).toString();
+const deleteIconUrl = new URL('./images/delete_icon.svg', import.meta.url).toString();
+const importIconUrl = new URL('./images/import_icon.svg', import.meta.url).toString();
+const exportIconUrl = new URL('./images/export_icon.svg', import.meta.url).toString();
+const continueIconUrl = new URL('./images/advance_icon.svg', import.meta.url).toString();
+const stepOverIconUrl = new URL('./images/step_icon.svg', import.meta.url).toString();
+
+@customElement('devtools-recorder-controller')
+export class RecorderController extends LitElement {
+  static override readonly styles = [recorderControllerStyles];
+
+  @state() declare private currentRecordingSession?: Models.RecordingSession.RecordingSession;
+  @state() declare private currentRecording: StoredRecording|undefined;
+  @state() declare private currentStep?: Models.Schema.Step;
+  @state() declare private recordingError?: Error;
+
+  #storage = Models.RecordingStorage.RecordingStorage.instance();
+  #screenshotStorage = Models.ScreenshotStorage.ScreenshotStorage.instance();
+
+  @state() declare private isRecording: boolean;
+  @state() declare private isToggling: boolean;
+
+  // TODO: we keep the functionality to allow/disallow replay but right now it's not used.
+  // It can be used to decide if we allow replay on a certain target for example.
+  #replayAllowed = true;
+  @state() declare private recordingPlayer?: Models.RecordingPlayer.RecordingPlayer;
+  @state() declare private lastReplayResult?: Models.RecordingPlayer.ReplayResult;
+  readonly #replayState: Components.RecordingView.ReplayState = {isPlaying: false, isPausedOnBreakpoint: false};
+
+  @state() declare private currentPage: Pages;
+  @state() declare private previousPage?: Pages;
+  #fileSelector?: HTMLInputElement;
+
+  @state() declare private sections?: Models.Section.Section[];
+  @state() declare private settings?: Models.RecordingSettings.RecordingSettings;
+
+  @state() declare private importError?: Error;
+
+  @state() declare private exportMenuExpanded: boolean;
+  #exportMenuButton: Buttons.Button.Button|undefined;
+
+  #stepBreakpointIndexes: Set<number> = new Set();
+
+  #builtInConverters: Readonly<Converters.Converter.Converter[]>;
+  @state() declare private extensionConverters: Converters.Converter.Converter[];
+  @state() declare private replayExtensions: Extensions.ExtensionManager.Extension[];
+
+  @state() declare private viewDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor;
+
+  #recorderSettings = new Models.RecorderSettings.RecorderSettings();
+  #shortcutHelper = new Models.RecorderShortcutHelper.RecorderShortcutHelper();
+
+  constructor() {
+    super();
+
+    this.isRecording = false;
+    this.isToggling = false;
+    this.exportMenuExpanded = false;
+
+    this.currentPage = Pages.StartPage;
+    if (this.#storage.getRecordings().length) {
+      this.#setCurrentPage(Pages.AllRecordingsPage);
+    }
+
+    const textEditorIndent = Common.Settings.Settings.instance().moduleSetting('textEditorIndent').get();
+    this.#builtInConverters = Object.freeze([
+      new Converters.JSONConverter.JSONConverter(textEditorIndent),
+      new Converters.PuppeteerReplayConverter.PuppeteerReplayConverter(textEditorIndent),
+      new Converters.PuppeteerConverter.PuppeteerConverter(textEditorIndent),
+      new Converters.LighthouseConverter.LighthouseConverter(textEditorIndent),
+    ]);
+
+    const extensionManager = Extensions.ExtensionManager.ExtensionManager.instance();
+    this.#updateExtensions(extensionManager.extensions());
+    extensionManager.addEventListener(Extensions.ExtensionManager.Events.ExtensionsUpdated, event => {
+      this.#updateExtensions(event.data);
+    });
+
+    // used in e2e tests only.
+    this.addEventListener('setrecording', (event: Event) => this.#onSetRecording(event));
+  }
+
+  override disconnectedCallback(): void {
+    super.disconnectedCallback();
+
+    if (this.currentRecordingSession) {
+      void this.currentRecordingSession.stop();
+    }
+  }
+
+  #updateExtensions(extensions: Extensions.ExtensionManager.Extension[]): void {
+    this.extensionConverters =
+        extensions.filter(extension => extension.getCapabilities().includes('export')).map((extension, idx) => {
+          return new Converters.ExtensionConverter.ExtensionConverter(idx, extension);
+        });
+    this.replayExtensions = extensions.filter(extension => extension.getCapabilities().includes('replay'));
+  }
+
+  setIsRecordingStateForTesting(isRecording: boolean): void {
+    this.isRecording = isRecording;
+  }
+
+  setRecordingStateForTesting(state: Components.RecordingView.ReplayState): void {
+    this.#replayState.isPlaying = state.isPlaying;
+    this.#replayState.isPausedOnBreakpoint = state.isPausedOnBreakpoint;
+  }
+
+  setCurrentPageForTesting(page: Pages): void {
+    this.#setCurrentPage(page);
+  }
+
+  getCurrentPageForTesting(): Pages {
+    return this.currentPage;
+  }
+
+  getCurrentRecordingForTesting(): StoredRecording|undefined {
+    return this.currentRecording;
+  }
+
+  getStepBreakpointIndexesForTesting(): number[] {
+    return [...this.#stepBreakpointIndexes.values()];
+  }
+
+  /**
+   * We should clear errors on every new action in the controller.
+   * TODO: think how to make handle this centrally so that in no case
+   * the error remains shown for longer than needed. Maybe a timer?
+   */
+  #clearError(): void {
+    this.importError = undefined;
+  }
+
+  async #importFile(file: File): Promise<void> {
+    const outputStream = new Common.StringOutputStream.StringOutputStream();
+    const reader = new Bindings.FileUtils.ChunkedFileReader(
+        file,
+        /* chunkSize */ 10000000);
+    const success = await reader.read(outputStream);
+    if (!success) {
+      throw reader.error();
+    }
+
+    let flow: Models.Schema.UserFlow|undefined;
+    try {
+      flow = Models.SchemaUtils.parse(JSON.parse(outputStream.data()));
+    } catch (error) {
+      this.importError = error;
+      return;
+    }
+    this.#setCurrentRecording(await this.#storage.saveRecording(flow));
+    this.#setCurrentPage(Pages.RecordingPage);
+    this.#clearError();
+  }
+
+  setCurrentRecordingForTesting(recording: StoredRecording|undefined): void {
+    this.#setCurrentRecording(recording);
+  }
+
+  getSectionsForTesting(): Array<Models.Section.Section>|undefined {
+    return this.sections;
+  }
+
+  #setCurrentRecording(recording: StoredRecording|undefined, opts: Partial<SetCurrentRecordingOptions> = {}): void {
+    const {keepBreakpoints = false, updateSession = false} = opts;
+    this.recordingPlayer?.abort();
+    this.currentStep = undefined;
+    this.recordingError = undefined;
+    this.lastReplayResult = undefined;
+    this.recordingPlayer = undefined;
+    this.#replayState.isPlaying = false;
+    this.#replayState.isPausedOnBreakpoint = false;
+    this.#stepBreakpointIndexes = keepBreakpoints ? this.#stepBreakpointIndexes : new Set();
+
+    if (recording) {
+      this.currentRecording = recording;
+      this.sections = Models.Section.buildSections(recording.flow.steps);
+      this.settings = this.#buildSettings(recording.flow);
+      if (updateSession && this.currentRecordingSession) {
+        this.currentRecordingSession.overwriteUserFlow(recording.flow);
+      }
+    } else {
+      this.currentRecording = undefined;
+      this.sections = undefined;
+      this.settings = undefined;
+    }
+
+    this.#updateScreenshotsForSections();
+  }
+
+  #setCurrentPage(page: Pages): void {
+    if (page === this.currentPage) {
+      return;
+    }
+
+    this.previousPage = this.currentPage;
+    this.currentPage = page;
+  }
+
+  #buildSettings(flow: Models.Schema.UserFlow): Models.RecordingSettings.RecordingSettings {
+    const steps = flow.steps;
+    const navigateStepIdx = steps.findIndex(step => step.type === 'navigate');
+    const settings: Models.RecordingSettings.RecordingSettings = {timeout: flow.timeout};
+    for (let i = navigateStepIdx - 1; i >= 0; i--) {
+      const step = steps[i];
+      if (!settings.viewportSettings && step.type === 'setViewport') {
+        settings.viewportSettings = step;
+      }
+      if (!settings.networkConditionsSettings && step.type === 'emulateNetworkConditions') {
+        settings.networkConditionsSettings = {...step};
+        for (const preset
+                 of [SDK.NetworkManager.OfflineConditions, SDK.NetworkManager.Slow3GConditions,
+                     SDK.NetworkManager.Fast3GConditions]) {
+          // Using i18nTitleKey as a title here because we only want to compare the parameters of the network conditions.
+          if (SDK.NetworkManager.networkConditionsEqual(
+                  {...preset, title: preset.i18nTitleKey || ''}, {...step, title: preset.i18nTitleKey || ''})) {
+            settings.networkConditionsSettings.title = preset.title instanceof Function ? preset.title() : preset.title;
+            settings.networkConditionsSettings.i18nTitleKey = preset.i18nTitleKey;
+          }
+        }
+      }
+    }
+    return settings;
+  }
+
+  #getMainTarget(): SDK.Target.Target {
+    const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
+    if (!target) {
+      throw new Error('Missing main page target');
+    }
+    return target;
+  }
+
+  #getSectionFromStep(step: Models.Schema.Step): Models.Section.Section|null {
+    if (!this.sections) {
+      return null;
+    }
+
+    for (const section of this.sections) {
+      if (section.steps.indexOf(step) !== -1) {
+        return section;
+      }
+    }
+
+    return null;
+  }
+
+  #updateScreenshotsForSections(): void {
+    if (!this.sections || !this.currentRecording) {
+      return;
+    }
+    const storageName = this.currentRecording.storageName;
+    for (let i = 0; i < this.sections.length; i++) {
+      const screenshot = this.#screenshotStorage.getScreenshotForSection(storageName, i);
+      this.sections[i].screenshot = screenshot || undefined;
+    }
+    this.requestUpdate();
+  }
+
+  #onAbortReplay(): void {
+    this.recordingPlayer?.abort();
+  }
+
+  async #onPlayViaExtension(extension: Extensions.ExtensionManager.Extension): Promise<void> {
+    if (!this.currentRecording || !this.#replayAllowed) {
+      return;
+    }
+    const pluginManager = PublicExtensions.RecorderPluginManager.RecorderPluginManager.instance();
+    const promise = pluginManager.once(PublicExtensions.RecorderPluginManager.Events.ShowViewRequested);
+    extension.replay(this.currentRecording.flow);
+    const descriptor = await promise;
+    this.viewDescriptor = descriptor;
+    Host.userMetrics.recordingReplayStarted(Host.UserMetrics.RecordingReplayStarted.ReplayViaExtension);
+  }
+
+  async #onPlayRecording(event: Components.RecordingView.PlayRecordingEvent): Promise<void> {
+    if (!this.currentRecording || !this.#replayAllowed) {
+      return;
+    }
+    if (this.viewDescriptor) {
+      this.viewDescriptor = undefined;
+    }
+    if (event.data.extension) {
+      return this.#onPlayViaExtension(event.data.extension);
+    }
+    Host.userMetrics.recordingReplayStarted(
+        event.data.targetPanel !== Components.RecordingView.TargetPanel.Default ?
+            Host.UserMetrics.RecordingReplayStarted.ReplayWithPerformanceTracing :
+            Host.UserMetrics.RecordingReplayStarted.ReplayOnly);
+    this.#replayState.isPlaying = true;
+    this.currentStep = undefined;
+    this.recordingError = undefined;
+    this.lastReplayResult = undefined;
+    const currentRecording = this.currentRecording;
+    this.#clearError();
+
+    await this.#disableDeviceModeIfEnabled();
+
+    this.recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+        this.currentRecording.flow, {speed: event.data.speed, breakpointIndexes: this.#stepBreakpointIndexes});
+
+    const withPerformanceTrace = event.data.targetPanel === Components.RecordingView.TargetPanel.PerformancePanel;
+    const sectionsWithScreenshot = new Set();
+    this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.Step, async ({data: {step, resolve}}) => {
+      this.currentStep = step;
+      const currentSection = this.#getSectionFromStep(step);
+      if (this.sections && currentSection && !sectionsWithScreenshot.has(currentSection)) {
+        sectionsWithScreenshot.add(currentSection);
+        const currentSectionIndex = this.sections.indexOf(currentSection);
+        const screenshot = await Models.ScreenshotUtils.takeScreenshot();
+        currentSection.screenshot = screenshot;
+        Models.ScreenshotStorage.ScreenshotStorage.instance().storeScreenshotForSection(
+            currentRecording.storageName, currentSectionIndex, screenshot);
+      }
+      resolve();
+    });
+
+    this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.Stop, () => {
+      this.#replayState.isPausedOnBreakpoint = true;
+      this.requestUpdate();
+    });
+
+    this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.Continue, () => {
+      this.#replayState.isPausedOnBreakpoint = false;
+      this.requestUpdate();
+    });
+
+    this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.Error, ({data: error}) => {
+      this.recordingError = error;
+      if (!withPerformanceTrace) {
+        this.#replayState.isPlaying = false;
+        this.recordingPlayer = undefined;
+      }
+      this.lastReplayResult = Models.RecordingPlayer.ReplayResult.Failure;
+      const errorMessage = error.message.toLowerCase();
+      if (errorMessage.startsWith('could not find element')) {
+        Host.userMetrics.recordingReplayFinished(Host.UserMetrics.RecordingReplayFinished.TimeoutErrorSelectors);
+      } else if (errorMessage.startsWith('waiting for target failed')) {
+        Host.userMetrics.recordingReplayFinished(Host.UserMetrics.RecordingReplayFinished.TimeoutErrorTarget);
+      } else {
+        Host.userMetrics.recordingReplayFinished(Host.UserMetrics.RecordingReplayFinished.OtherError);
+      }
+    });
+
+    this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.Done, () => {
+      if (!withPerformanceTrace) {
+        this.#replayState.isPlaying = false;
+        this.recordingPlayer = undefined;
+      }
+      this.lastReplayResult = Models.RecordingPlayer.ReplayResult.Success;
+      // Dispatch an event for e2e testing.
+      this.dispatchEvent(new Events.ReplayFinishedEvent());
+      Host.userMetrics.recordingReplayFinished(Host.UserMetrics.RecordingReplayFinished.Success);
+    });
+
+    this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.Abort, () => {
+      this.currentStep = undefined;
+      this.recordingError = undefined;
+      this.lastReplayResult = undefined;
+      this.#replayState.isPlaying = false;
+    });
+
+    let resolveWithEvents = (_events: Object[]): void => {};
+    const eventsPromise = new Promise<Object[]>((resolve): void => {
+      resolveWithEvents = resolve;
+    });
+
+    let performanceTracing = null;
+    switch (event.data?.targetPanel) {
+      case Components.RecordingView.TargetPanel.PerformancePanel:
+        performanceTracing = new Tracing.PerformanceTracing.PerformanceTracing(this.#getMainTarget(), {
+          tracingBufferUsage(): void{},
+          eventsRetrievalProgress(): void{},
+          tracingComplete(events: Object[]): void {
+            resolveWithEvents(events);
+          },
+        });
+        break;
+    }
+
+    if (performanceTracing) {
+      await performanceTracing.start();
+    }
+
+    this.#setTouchEmulationAllowed(false);
+    await this.recordingPlayer.play();
+    this.#setTouchEmulationAllowed(true);
+
+    if (performanceTracing) {
+      await performanceTracing.stop();
+      const events = await eventsPromise;
+      this.#replayState.isPlaying = false;
+      this.recordingPlayer = undefined;
+      await UI.InspectorView.InspectorView.instance().showPanel(event.data?.targetPanel as string);
+      switch (event.data?.targetPanel) {
+        case Components.RecordingView.TargetPanel.PerformancePanel:
+          Timeline.TimelinePanel.TimelinePanel.instance().loadFromEvents(events as SDK.TracingManager.EventPayload[]);
+          break;
+      }
+    }
+  }
+
+  async #disableDeviceModeIfEnabled(): Promise<void> {
+    try {
+      const deviceModeWrapper = Emulation.DeviceModeWrapper.DeviceModeWrapper.instance();
+      if (deviceModeWrapper.isDeviceModeOn()) {
+        deviceModeWrapper.toggleDeviceMode();
+        const emulationModel = this.#getMainTarget().model(SDK.EmulationModel.EmulationModel);
+        await emulationModel?.emulateDevice(null);
+      }
+    } catch {
+      // in the hosted mode, when the DeviceMode toolbar is not supported,
+      // Emulation.DeviceModeWrapper.DeviceModeWrapper.instance throws an exception.
+    }
+  }
+
+  #setTouchEmulationAllowed(touchEmulationAllowed: boolean): void {
+    const emulationModel = this.#getMainTarget().model(SDK.EmulationModel.EmulationModel);
+    emulationModel?.setTouchEmulationAllowed(touchEmulationAllowed);
+  }
+
+  async #onSetRecording(event: Event): Promise<void> {
+    const json = JSON.parse((event as CustomEvent).detail);
+    this.#setCurrentRecording(await this.#storage.saveRecording(Models.SchemaUtils.parse(json)));
+    this.#setCurrentPage(Pages.RecordingPage);
+    this.#clearError();
+  }
+
+  // Used by e2e tests to inspect the current recording.
+  getUserFlow(): Models.Schema.UserFlow|undefined {
+    return this.currentRecording?.flow;
+  }
+
+  async #handleRecordingChanged(event: Components.StepView.StepChanged): Promise<void> {
+    if (!this.currentRecording) {
+      throw new Error('Current recording expected to be defined.');
+    }
+    const recording = {
+      ...this.currentRecording,
+      flow: {
+        ...this.currentRecording.flow,
+        steps: this.currentRecording.flow.steps.map(step => step === event.currentStep ? event.newStep : step),
+      },
+    };
+    this.#setCurrentRecording(
+        await this.#storage.updateRecording(recording.storageName, recording.flow),
+        {keepBreakpoints: true, updateSession: true});
+  }
+
+  async #handleStepAdded(event: Components.StepView.AddStep): Promise<void> {
+    if (!this.currentRecording) {
+      throw new Error('Current recording expected to be defined.');
+    }
+    const stepOrSection = event.stepOrSection;
+    let step;
+    let position = event.position;
+    if ('steps' in stepOrSection) {
+      // section
+      const sectionIdx = this.sections?.indexOf(stepOrSection);
+      if (sectionIdx === undefined || sectionIdx === -1) {
+        throw new Error('There is no section to add a step to');
+      }
+      if (event.position === Components.StepView.AddStepPosition.AFTER) {
+        if (this.sections?.[sectionIdx].steps.length) {
+          step = this.sections?.[sectionIdx].steps[0];
+          position = Components.StepView.AddStepPosition.BEFORE;
+        } else {
+          step = this.sections?.[sectionIdx].causingStep;
+          position = Components.StepView.AddStepPosition.AFTER;
+        }
+      } else {
+        if (sectionIdx <= 0) {
+          throw new Error('There is no section to add a step to');
+        }
+        const prevSection = this.sections?.[sectionIdx - 1];
+        step = prevSection?.steps[prevSection.steps.length - 1];
+        position = Components.StepView.AddStepPosition.AFTER;
+      }
+    } else {
+      // step
+      step = stepOrSection;
+    }
+    if (!step) {
+      throw new Error('Anchor step is not found when adding a step');
+    }
+    const steps = this.currentRecording.flow.steps;
+    const currentIndex = steps.indexOf(step);
+    const indexToInsertAt = currentIndex + (position === Components.StepView.AddStepPosition.BEFORE ? 0 : 1);
+    steps.splice(indexToInsertAt, 0, {type: Models.Schema.StepType.WaitForElement, selectors: ['body']});
+    const recording = {...this.currentRecording, flow: {...this.currentRecording.flow, steps}};
+    Host.userMetrics.recordingEdited(Host.UserMetrics.RecordingEdited.StepAdded);
+    this.#stepBreakpointIndexes = new Set([...this.#stepBreakpointIndexes.values()].map(breakpointIndex => {
+      if (indexToInsertAt > breakpointIndex) {
+        return breakpointIndex;
+      }
+
+      return breakpointIndex + 1;
+    }));
+    this.#setCurrentRecording(
+        await this.#storage.updateRecording(recording.storageName, recording.flow),
+        {keepBreakpoints: true, updateSession: true});
+  }
+
+  async #handleRecordingTitleChanged(event: Components.RecordingView.RecordingTitleChangedEvent): Promise<void> {
+    if (!this.currentRecording) {
+      throw new Error('Current recording expected to be defined.');
+    }
+
+    const flow = {...this.currentRecording.flow, title: event.title};
+    this.#setCurrentRecording(await this.#storage.updateRecording(this.currentRecording.storageName, flow));
+  }
+
+  async #handleStepRemoved(event: Components.StepView.RemoveStep): Promise<void> {
+    if (!this.currentRecording) {
+      throw new Error('Current recording expected to be defined.');
+    }
+
+    const steps = this.currentRecording.flow.steps;
+    const currentIndex = steps.indexOf(event.step);
+    steps.splice(currentIndex, 1);
+    const flow = {...this.currentRecording.flow, steps};
+    Host.userMetrics.recordingEdited(Host.UserMetrics.RecordingEdited.StepRemoved);
+    this.#stepBreakpointIndexes = new Set([...this.#stepBreakpointIndexes.values()]
+                                              .map(breakpointIndex => {
+                                                if (currentIndex > breakpointIndex) {
+                                                  return breakpointIndex;
+                                                }
+
+                                                if (currentIndex === breakpointIndex) {
+                                                  return -1;
+                                                }
+
+                                                return breakpointIndex - 1;
+                                              })
+                                              .filter(index => index >= 0));
+    this.#setCurrentRecording(
+        await this.#storage.updateRecording(this.currentRecording.storageName, flow),
+        {keepBreakpoints: true, updateSession: true});
+  }
+
+  async #onNetworkConditionsChanged(event: Components.RecordingView.NetworkConditionsChanged): Promise<void> {
+    if (!this.currentRecording) {
+      throw new Error('Current recording expected to be defined.');
+    }
+    const navigateIdx = this.currentRecording.flow.steps.findIndex(step => step.type === 'navigate');
+    if (navigateIdx === -1) {
+      throw new Error('Current recording does not have a navigate step');
+    }
+    const emulateNetworkConditionsIdx = this.currentRecording.flow.steps.findIndex((step, idx) => {
+      if (idx >= navigateIdx) {
+        return false;
+      }
+      return step.type === 'emulateNetworkConditions';
+    });
+    if (!event.data) {
+      // Delete step if present.
+      if (emulateNetworkConditionsIdx !== -1) {
+        this.currentRecording.flow.steps.splice(emulateNetworkConditionsIdx, 1);
+      }
+    } else if (emulateNetworkConditionsIdx === -1) {
+      // Insert at the first position.
+      this.currentRecording.flow.steps.splice(
+          0, 0,
+          Models.SchemaUtils.createEmulateNetworkConditionsStep(
+              {download: event.data.download, upload: event.data.upload, latency: event.data.latency}));
+    } else {
+      // Update existing step.
+      const step =
+          this.currentRecording.flow.steps[emulateNetworkConditionsIdx] as Models.Schema.EmulateNetworkConditionsStep;
+      step.download = event.data.download;
+      step.upload = event.data.upload;
+      step.latency = event.data.latency;
+    }
+    this.#setCurrentRecording(
+        await this.#storage.updateRecording(this.currentRecording.storageName, this.currentRecording.flow));
+  }
+
+  async #onTimeoutChanged(event: Components.RecordingView.TimeoutChanged): Promise<void> {
+    if (!this.currentRecording) {
+      throw new Error('Current recording expected to be defined.');
+    }
+    this.currentRecording.flow.timeout = event.data;
+    this.#setCurrentRecording(
+        await this.#storage.updateRecording(this.currentRecording.storageName, this.currentRecording.flow));
+  }
+
+  async #onDeleteRecording(event: Event): Promise<void> {
+    event.stopPropagation();
+    if (event instanceof Components.RecordingListView.DeleteRecordingEvent) {
+      await this.#storage.deleteRecording(event.storageName);
+      this.#screenshotStorage.deleteScreenshotsForRecording(event.storageName);
+      this.requestUpdate();
+    } else {
+      if (!this.currentRecording) {
+        return;
+      }
+      await this.#storage.deleteRecording(this.currentRecording.storageName);
+      this.#screenshotStorage.deleteScreenshotsForRecording(this.currentRecording.storageName);
+    }
+    if ((await this.#storage.getRecordings()).length) {
+      this.#setCurrentPage(Pages.AllRecordingsPage);
+    } else {
+      this.#setCurrentPage(Pages.StartPage);
+    }
+    this.#setCurrentRecording(undefined);
+    this.#clearError();
+  }
+
+  #onCreateNewRecording(event?: Event): void {
+    event?.stopPropagation();
+    this.#setCurrentPage(Pages.CreateRecordingPage);
+    this.#clearError();
+  }
+
+  async #onRecordingStarted(event: Components.CreateRecordingView.RecordingStartedEvent): Promise<void> {
+    // Recording is not available in device mode.
+    await this.#disableDeviceModeIfEnabled();
+
+    // Setting up some variables to notify the user we are initializing a recording.
+    this.isToggling = true;
+    this.#clearError();
+
+    // -- Recording logic starts here --
+    Host.userMetrics.recordingToggled(Host.UserMetrics.RecordingToggled.RecordingStarted);
+    this.currentRecordingSession = new Models.RecordingSession.RecordingSession(this.#getMainTarget(), {
+      title: event.name,
+      selectorAttribute: event.selectorAttribute,
+      selectorTypesToRecord: event.selectorTypesToRecord.length ? event.selectorTypesToRecord :
+                                                                  Object.values(Models.Schema.SelectorType),
+    });
+    this.#setCurrentRecording(await this.#storage.saveRecording(this.currentRecordingSession.cloneUserFlow()));
+
+    let previousSectionIndex = -1;
+    let screenshotPromise:|Promise<Models.ScreenshotStorage.Screenshot>|undefined;
+    const takeScreenshot = async(currentRecording: StoredRecording): Promise<void> => {
+      if (!this.sections) {
+        throw new Error('Could not find sections.');
+      }
+
+      const currentSectionIndex = this.sections.length - 1;
+      const currentSection = this.sections[currentSectionIndex];
+      if (screenshotPromise || previousSectionIndex === currentSectionIndex) {
+        return;
+      }
+
+      screenshotPromise = Models.ScreenshotUtils.takeScreenshot();
+      const screenshot = await screenshotPromise;
+      screenshotPromise = undefined;
+      currentSection.screenshot = screenshot;
+      Models.ScreenshotStorage.ScreenshotStorage.instance().storeScreenshotForSection(
+          currentRecording.storageName, currentSectionIndex, screenshot);
+      previousSectionIndex = currentSectionIndex;
+      this.#updateScreenshotsForSections();
+    };
+
+    this.currentRecordingSession.addEventListener(
+        Models.RecordingSession.Events.RecordingUpdated, async ({data}: {data: Models.Schema.UserFlow}) => {
+          if (!this.currentRecording) {
+            throw new Error('No current recording found');
+          }
+          this.#setCurrentRecording(await this.#storage.updateRecording(this.currentRecording.storageName, data));
+          const recordingView = this.shadowRoot?.querySelector('devtools-recording-view');
+          recordingView?.scrollToBottom();
+
+          await takeScreenshot(this.currentRecording);
+        });
+
+    this.currentRecordingSession.addEventListener(
+        Models.RecordingSession.Events.RecordingStopped, async ({data}: {data: Models.Schema.UserFlow}) => {
+          if (!this.currentRecording) {
+            throw new Error('No current recording found');
+          }
+          Host.userMetrics.keyboardShortcutFired(Actions.RecorderActions.StartRecording);
+          this.#setCurrentRecording(await this.#storage.updateRecording(this.currentRecording.storageName, data));
+          await this.#onRecordingFinished();
+        });
+
+    await this.currentRecordingSession.start();
+    // -- Recording logic ends here --
+
+    // Setting up some variables to notify the user we are finished initialization.
+    this.isToggling = false;
+    this.isRecording = true;
+    this.#setCurrentPage(Pages.RecordingPage);
+
+    // Dispatch an event for e2e testing.
+    this.dispatchEvent(new Events.RecordingStateChangedEvent((this.currentRecording as StoredRecording).flow));
+  }
+
+  async #onRecordingFinished(): Promise<void> {
+    if (!this.currentRecording || !this.currentRecordingSession) {
+      throw new Error('Recording was never started');
+    }
+
+    // Setting up some variables to notify the user we are finalizing a recording.
+    this.isToggling = true;
+    this.#clearError();
+
+    // -- Recording logic starts here --
+    Host.userMetrics.recordingToggled(Host.UserMetrics.RecordingToggled.RecordingFinished);
+    await this.currentRecordingSession.stop();
+    this.currentRecordingSession = undefined;
+    // -- Recording logic ends here --
+
+    // Setting up some variables to notify the user we are finished finalizing.
+    this.isToggling = false;
+    this.isRecording = false;
+
+    // Dispatch an event for e2e testing.
+    this.dispatchEvent(new Events.RecordingStateChangedEvent(this.currentRecording.flow));
+  }
+
+  async #onRecordingCancelled(): Promise<void> {
+    if (this.previousPage) {
+      this.#setCurrentPage(this.previousPage);
+    }
+  }
+
+  async #onRecordingSelected(event: Event): Promise<void> {
+    const storageName = event instanceof Components.RecordingListView.OpenRecordingEvent ||
+            event instanceof Components.RecordingListView.PlayRecordingEvent ?
+        event.storageName :
+        ((event as InputEvent).target as HTMLSelectElement)?.value;
+    this.#setCurrentRecording(await this.#storage.getRecording(storageName));
+    if (this.currentRecording) {
+      this.#setCurrentPage(Pages.RecordingPage);
+    } else if (storageName === Pages.StartPage) {
+      this.#setCurrentPage(Pages.StartPage);
+    } else if (storageName === Pages.AllRecordingsPage) {
+      this.#setCurrentPage(Pages.AllRecordingsPage);
+    }
+  }
+
+  async #onExportOptionSelected(event: Menus.SelectMenu.SelectMenuItemSelectedEvent): Promise<void> {
+    if (typeof event.itemValue !== 'string') {
+      throw new Error('Invalid export option value');
+    }
+    if (event.itemValue === GET_EXTENSIONS_MENU_ITEM) {
+      Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(GET_EXTENSIONS_URL);
+      return;
+    }
+    if (!this.currentRecording) {
+      throw new Error('No recording selected');
+    }
+    const id = event.itemValue;
+    const byId = (converter: Converters.Converter.Converter): boolean => converter.getId() === id;
+    const converter = this.#builtInConverters.find(byId) || this.extensionConverters.find(byId);
+    if (!converter) {
+      throw new Error('No recording selected');
+    }
+    const [content] = await converter.stringify(this.currentRecording.flow);
+    await this.#exportContent(converter.getFilename(this.currentRecording.flow), content);
+    const builtInMetric = CONVERTER_ID_TO_METRIC[converter.getId()];
+    if (builtInMetric) {
+      Host.userMetrics.recordingExported(builtInMetric);
+    } else if (converter.getId().startsWith(Converters.ExtensionConverter.EXTENSION_PREFIX)) {
+      Host.userMetrics.recordingExported(Host.UserMetrics.RecordingExported.ToExtension);
+    } else {
+      throw new Error('Could not find a metric for the export option with id = ' + id);
+    }
+  }
+
+  async #exportContent(suggestedName: string, data: string): Promise<void> {
+    try {
+      const handle = await window.showSaveFilePicker({suggestedName});
+      const writable = await handle.createWritable();
+      await writable.write(data);
+      await writable.close();
+    } catch (error) {
+      // If the user aborts the action no need to report it, otherwise do.
+      if (error.name === 'AbortError') {
+        return;
+      }
+
+      throw error;
+    }
+  }
+
+  async #handleAddAssertionEvent(): Promise<void> {
+    if (!this.currentRecordingSession || !this.currentRecording) {
+      return;
+    }
+    const flow = this.currentRecordingSession.cloneUserFlow();
+    flow.steps.push({type: 'waitForElement' as Models.Schema.StepType.WaitForElement, selectors: [['.cls']]});
+    this.#setCurrentRecording(
+        await this.#storage.updateRecording(this.currentRecording.storageName, flow),
+        {keepBreakpoints: true, updateSession: true});
+    Host.userMetrics.recordingAssertion(Host.UserMetrics.RecordingAssertion.AssertionAdded);
+    await this.updateComplete;
+    this.renderRoot.querySelector('devtools-recording-view')
+        ?.shadowRoot?.querySelector('.section:last-child devtools-step-view:last-of-type')
+        ?.shadowRoot?.querySelector<HTMLElement>('.action')
+        ?.click();
+  }
+
+  #onImportRecording(event: Event): void {
+    event.stopPropagation();
+    this.#clearError();
+    this.#fileSelector = UI.UIUtils.createFileSelectorElement(this.#importFile.bind(this));
+    this.#fileSelector.click();
+  }
+
+  async #onPlayRecordingByName(event: Components.RecordingListView.PlayRecordingEvent): Promise<void> {
+    await this.#onRecordingSelected(event);
+    await this.#onPlayRecording(new Components.RecordingView.PlayRecordingEvent(
+        {targetPanel: Components.RecordingView.TargetPanel.Default, speed: this.#recorderSettings.speed}));
+  }
+
+  # AddBreakpointEvent): void => {
+    this.#stepBreakpointIndexes.add(event.index);
+    this.recordingPlayer?.updateBreakpointIndexes(this.#stepBreakpointIndexes);
+    this.requestUpdate();
+  };
+
+  # RemoveBreakpointEvent): void => {
+    this.#stepBreakpointIndexes.delete(event.index);
+    this.recordingPlayer?.updateBreakpointIndexes(this.#stepBreakpointIndexes);
+    this.requestUpdate();
+  };
+
+  #onExtensionViewClosed(): void {
+    this.viewDescriptor = undefined;
+  }
+
+  handleActions(actionId: Actions.RecorderActions): void {
+    if (!this.isActionPossible(actionId)) {
+      return;
+    }
+
+    switch (actionId) {
+      case Actions.RecorderActions.CreateRecording:
+        this.#onCreateNewRecording();
+        return;
+
+      case Actions.RecorderActions.StartRecording:
+        if (this.currentPage !== Pages.CreateRecordingPage && !this.isRecording) {
+          this.#shortcutHelper.handleShortcut(this.#onRecordingStarted.bind(
+              this,
+              new Components.CreateRecordingView.RecordingStartedEvent(
+                  this.#recorderSettings.defaultTitle, this.#recorderSettings.defaultSelectors,
+                  this.#recorderSettings.selectorAttribute)));
+        } else if (this.currentPage === Pages.CreateRecordingPage) {
+          const view = this.renderRoot.querySelector('devtools-create-recording-view');
+          if (view) {
+            this.#shortcutHelper.handleShortcut(view.startRecording.bind(view));
+          }
+        } else if (this.isRecording) {
+          void this.#onRecordingFinished();
+        }
+        return;
+
+      case Actions.RecorderActions.ReplayRecording:
+        void this.#onPlayRecording(new Components.RecordingView.PlayRecordingEvent(
+            {targetPanel: Components.RecordingView.TargetPanel.Default, speed: this.#recorderSettings.speed}));
+        return;
+
+      case Actions.RecorderActions.ToggleCodeView: {
+        const view = this.renderRoot.querySelector('devtools-recording-view');
+        if (view) {
+          view.showCodeToggle();
+        }
+        return;
+      }
+    }
+  }
+
+  isActionPossible(actionId: Actions.RecorderActions): boolean {
+    switch (actionId) {
+      case Actions.RecorderActions.CreateRecording:
+        return !this.isRecording && !this.#replayState.isPlaying;
+      case Actions.RecorderActions.StartRecording:
+        return !this.#replayState.isPlaying;
+      case Actions.RecorderActions.ReplayRecording:
+        return (this.currentPage === Pages.RecordingPage && !this.#replayState.isPlaying);
+      case Actions.RecorderActions.ToggleCodeView:
+        return this.currentPage === Pages.RecordingPage;
+    }
+  }
+
+  #getShortcutsInfo(): Dialogs.ShortcutDialog.Shortcut[] {
+    const getBindingForAction = (action: Actions.RecorderActions): string[] => {
+      const shortcuts = UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutsForAction(action);
+
+      return shortcuts.map(shortcut => shortcut.title());
+    };
+
+    return [
+      {
+        title: i18nString(UIStrings.startStopRecording),
+        bindings: getBindingForAction(Actions.RecorderActions.StartRecording),
+      },
+      {
+        title: i18nString(UIStrings.replayRecording),
+        bindings: getBindingForAction(Actions.RecorderActions.ReplayRecording),
+      },
+      {title: i18nString(UIStrings.copyShortcut), bindings: [`${Host.Platform.isMac() ? '⌘ C' : 'Ctrl+C'}`]},
+      {title: i18nString(UIStrings.toggleCode), bindings: getBindingForAction(Actions.RecorderActions.ToggleCodeView)},
+    ];
+  }
+
+  #renderCurrentPage(): LitHtml.TemplateResult {
+    switch (this.currentPage) {
+      case Pages.StartPage:
+        return this.#renderStartPage();
+      case Pages.AllRecordingsPage:
+        return this.#renderAllRecordingsPage();
+      case Pages.RecordingPage:
+        return this.#renderRecordingPage();
+      case Pages.CreateRecordingPage:
+        return this.#renderCreateRecordingPage();
+    }
+  }
+
+  #renderAllRecordingsPage(): LitHtml.TemplateResult {
+    const recordings = this.#storage.getRecordings();
+    // clang-format off
+    return html`
+      <${Components.RecordingListView.RecordingListView.litTagName}
+        .recordings=${recordings.map(recording => ({
+          storageName: recording.storageName,
+          name: recording.flow.title,
+        }))}
+        .replayAllowed=${this.#replayAllowed}
+        @createrecording=${this.#onCreateNewRecording}
+        @deleterecording=${this.#onDeleteRecording}
+        @openrecording=${this.#onRecordingSelected}
+        @playrecording=${this.#onPlayRecordingByName}
+        >
+      </${Components.RecordingListView.RecordingListView.litTagName}>
+    `;
+    // clang-format on
+  }
+
+  #renderStartPage(): LitHtml.TemplateResult {
+    // clang-format off
+    return html`
+      <${Components.StartView.StartView.litTagName}
+        @createrecording=${this.#onCreateNewRecording}
+      ></${Components.StartView.StartView.litTagName}>
+    `;
+    // clang-format on
+  }
+
+  #renderRecordingPage(): LitHtml.TemplateResult {
+    // clang-format off
+    return html`
+      <${Components.RecordingView.RecordingView.litTagName}
+        .data=${
+          {
+            recording: this.currentRecording?.flow,
+            replayState: this.#replayState,
+            isRecording: this.isRecording,
+            recordingTogglingInProgress: this.isToggling,
+            currentStep: this.currentStep,
+            currentError: this.recordingError,
+            sections: this.sections,
+            settings: this.settings,
+            recorderSettings: this.#recorderSettings,
+            lastReplayResult: this.lastReplayResult,
+            replayAllowed: this.#replayAllowed,
+            breakpointIndexes: this.#stepBreakpointIndexes,
+            builtInConverters: this.#builtInConverters,
+            extensionConverters: this.extensionConverters,
+            replayExtensions: this.replayExtensions,
+            extensionDescriptor: this.viewDescriptor,
+          } as Components.RecordingView.RecordingViewData
+        }
+        @networkconditionschanged=${this.#onNetworkConditionsChanged}
+        @timeoutchanged=${this.#onTimeoutChanged}
+        @requestselectorattribute=${(
+          event: Controllers.SelectorPicker.RequestSelectorAttributeEvent,
+        ): void => {
+          event.send(this.currentRecording?.flow.selectorAttribute);
+        }}
+        @recordingfinished=${this.#onRecordingFinished}
+        @stepchanged=${this.#handleRecordingChanged.bind(this)}
+        @recordingtitlechanged=${this.#handleRecordingTitleChanged.bind(this)}
+        @addstep=${this.#handleStepAdded.bind(this)}
+        @removestep=${this.#handleStepRemoved.bind(this)}
+        @addbreakpoint=${this.#onAddBreakpoint}
+        @removebreakpoint=${this.#onRemoveBreakpoint}
+        @playrecording=${this.#onPlayRecording}
+        @abortreplay=${this.#onAbortReplay}
+        @recorderextensionviewclosed=${this.#onExtensionViewClosed}
+        @addassertion=${this.#handleAddAssertionEvent}
+      ></${Components.RecordingView.RecordingView.litTagName}>
+    `;
+    // clang-format on
+  }
+
+  #renderCreateRecordingPage(): LitHtml.TemplateResult {
+    // clang-format off
+    return html`
+      <${Components.CreateRecordingView.CreateRecordingView.litTagName}
+        .data=${
+          {
+            recorderSettings: this.#recorderSettings,
+          } as Components.CreateRecordingView.CreateRecordingViewData
+        }
+        @recordingstarted=${this.#onRecordingStarted}
+        @recordingcancelled=${this.#onRecordingCancelled}
+      ></${Components.CreateRecordingView.CreateRecordingView.litTagName}>
+    `;
+    // clang-format on
+  }
+
+  #getExportMenuButton = (): Buttons.Button.Button => {
+    if (!this.#exportMenuButton) {
+      throw new Error('#exportMenuButton not found');
+    }
+    return this.#exportMenuButton;
+  };
+
+  #onExportRecording(event: Event): void {
+    event.stopPropagation();
+    this.#clearError();
+    this.exportMenuExpanded = !this.exportMenuExpanded;
+  }
+
+  #onExportMenuClosed(): void {
+    this.exportMenuExpanded = false;
+  }
+
+  protected override render(): LitHtml.TemplateResult {
+    const recordings = this.#storage.getRecordings();
+    const selectValue: string = this.currentRecording ? this.currentRecording.storageName : this.currentPage;
+    // clang-format off
+    const values = [
+      recordings.length === 0
+        ? {
+            value: Pages.StartPage,
+            name: 'No recordings',
+            selected: selectValue === Pages.StartPage,
+          }
+        : {
+            value: Pages.AllRecordingsPage,
+            name: `${recordings.length} recording(s)`,
+            selected: selectValue === Pages.AllRecordingsPage,
+          },
+      ...recordings.map(recording => ({
+        value: recording.storageName,
+        name: recording.flow.title,
+        selected: selectValue === recording.storageName,
+      })),
+    ];
+
+    return html`
+        <div class="wrapper">
+          <div class="header">
+            <${Buttons.Button.Button.litTagName}
+              @click=${this.#onCreateNewRecording}
+              .data=${
+                {
+                  variant: Buttons.Button.Variant.TOOLBAR,
+                  iconUrl: plusIconUrl,
+                  disabled:
+                    this.#replayState.isPlaying ||
+                    this.isRecording ||
+                    this.isToggling,
+                  title: Models.Tooltip.getTooltipForActions(
+                    i18nString(UIStrings.createRecording),
+                    Actions.RecorderActions.CreateRecording,
+                  ),
+                } as Buttons.Button.ButtonData
+              }
+            ></${Buttons.Button.Button.litTagName}>
+            <div class="separator"></div>
+            <select
+              .disabled=${
+                recordings.length === 0 ||
+                this.#replayState.isPlaying ||
+                this.isRecording ||
+                this.isToggling
+              }
+              @click=${(e: Event): void => e.stopPropagation()}
+              @change=${this.#onRecordingSelected}
+            >
+              ${LitHtml.Directives.repeat(
+                values,
+                item => item.value,
+                item => {
+                  return html`<option .selected=${item.selected} value=${item.value}>${item.name}</option>`;
+                },
+              )}
+            </select>
+            <div class="separator"></div>
+            <${Buttons.Button.Button.litTagName}
+              @click=${this.#onImportRecording}
+              .data=${
+                {
+                  variant: Buttons.Button.Variant.TOOLBAR,
+                  iconUrl: importIconUrl,
+                  title: i18nString(UIStrings.importRecording),
+                } as Buttons.Button.ButtonData
+              }
+            ></${Buttons.Button.Button.litTagName}>
+            <${Buttons.Button.Button.litTagName}
+              id='origin'
+              @click=${this.#onExportRecording}
+              on-render=${ComponentHelpers.Directives.nodeRenderedCallback(
+                node => {
+                  this.#exportMenuButton = node as Buttons.Button.Button;
+                },
+              )}
+              .data=${
+                {
+                  variant: Buttons.Button.Variant.TOOLBAR,
+                  iconUrl: exportIconUrl,
+                  title: i18nString(UIStrings.exportRecording),
+                  disabled: !this.currentRecording,
+                } as Buttons.Button.ButtonData
+              }
+            ></${Buttons.Button.Button.litTagName}>
+            <${Menus.Menu.Menu.litTagName}
+              @menucloserequest=${this.#onExportMenuClosed}
+              @menuitemselected=${this.#onExportOptionSelected}
+              .origin=${this.#getExportMenuButton}
+              .showDivider=${false}
+              .showSelectedItem=${false}
+              .showConnector=${false}
+              .open=${this.exportMenuExpanded}
+            >
+              <${Menus.Menu.MenuGroup.litTagName} .name=${i18nString(
+      UIStrings.export,
+    )}>
+                ${LitHtml.Directives.repeat(
+                  this.#builtInConverters,
+                  converter => {
+                    return html`
+                    <${
+                      Menus.Menu.MenuItem.litTagName
+                    } .value=${converter.getId()}>
+                      ${converter.getFormatName()}
+                    </${Menus.Menu.MenuItem.litTagName}>
+                  `;
+                  },
+                )}
+              </${Menus.Menu.MenuGroup.litTagName}>
+              <${Menus.Menu.MenuGroup.litTagName} .name=${i18nString(
+      UIStrings.exportViaExtensions,
+    )}>
+                ${LitHtml.Directives.repeat(
+                  this.extensionConverters,
+                  converter => {
+                    return html`
+                    <${
+                      Menus.Menu.MenuItem.litTagName
+                    } .value=${converter.getId()}>
+                    ${converter.getFormatName()}
+                    </${Menus.Menu.MenuItem.litTagName}>
+                  `;
+                  },
+                )}
+                <${
+                  Menus.Menu.MenuItem.litTagName
+                } .value=${GET_EXTENSIONS_MENU_ITEM}>
+                  ${i18nString(UIStrings.getExtensions)}
+                </${Menus.Menu.MenuItem.litTagName}>
+              </${Menus.Menu.MenuGroup.litTagName}>
+            </${Menus.Menu.Menu.litTagName}>
+            <${Buttons.Button.Button.litTagName}
+              @click=${this.#onDeleteRecording}
+              .data=${
+                {
+                  variant: Buttons.Button.Variant.TOOLBAR,
+                  iconUrl: deleteIconUrl,
+                  disabled:
+                    !this.currentRecording ||
+                    this.#replayState.isPlaying ||
+                    this.isRecording ||
+                    this.isToggling,
+                  title: i18nString(UIStrings.deleteRecording),
+                } as Buttons.Button.ButtonData
+              }
+            ></${Buttons.Button.Button.litTagName}>
+            <div class="separator"></div>
+            <button class="continue-button"
+              .disabled=${
+                !this.recordingPlayer ||
+                !this.#replayState.isPausedOnBreakpoint
+              }
+              title=${i18nString(UIStrings.continueReplay)}
+              @click=${(): void => this.recordingPlayer?.continue()}>
+                <${IconButton.Icon.Icon.litTagName}
+                  .data=${
+                    {
+                      iconPath: continueIconUrl,
+                      color: 'var(--icon-color)',
+                    } as IconButton.Icon.IconData
+                  }
+                ></${IconButton.Icon.Icon.litTagName}>
+            </button>
+            <${Buttons.Button.Button.litTagName}
+              @click=${(): void => this.recordingPlayer?.stepOver()}
+              .data=${
+                {
+                  variant: Buttons.Button.Variant.TOOLBAR,
+                  iconUrl: stepOverIconUrl,
+                  disabled:
+                    !this.recordingPlayer ||
+                    !this.#replayState.isPausedOnBreakpoint,
+                  title: i18nString(UIStrings.stepOverReplay),
+                } as Buttons.Button.ButtonData
+              }
+            ></${Buttons.Button.Button.litTagName}>
+            <div class="feedback">
+              <x-link class="x-link" href=${
+                Components.StartView.FEEDBACK_URL
+              }>${i18nString(UIStrings.sendFeedback)}</x-link>
+            </div>
+            <div class="separator"></div>
+            <${Dialogs.ShortcutDialog.ShortcutDialog.litTagName}
+              .data=${
+                {
+                  shortcuts: this.#getShortcutsInfo(),
+                } as Dialogs.ShortcutDialog.ShortcutDialogData
+              }
+            ></${Dialogs.ShortcutDialog.ShortcutDialog.litTagName}>
+          </div>
+          ${
+            this.importError
+              ? html`<div class='error'>Import error: ${
+                  this.importError.message
+                }</div>`
+              : ''
+          }
+          ${this.#renderCurrentPage()}
+        </div>
+      `;
+    // clang-format on
+  }
+}
diff --git a/front_end/panels/recorder/RecorderEvents.ts b/front_end/panels/recorder/RecorderEvents.ts
new file mode 100644
index 0000000..9903e5c
--- /dev/null
+++ b/front_end/panels/recorder/RecorderEvents.ts
@@ -0,0 +1,24 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type * as Models from './models/models.js';
+
+export class ReplayFinishedEvent extends Event {
+  static readonly eventName = 'replayfinished';
+
+  constructor() {
+    super(ReplayFinishedEvent.eventName, {bubbles: true, composed: true});
+  }
+}
+
+export class RecordingStateChangedEvent extends Event {
+  static readonly eventName = 'recordingstatechanged';
+
+  constructor(public recording: Models.Schema.UserFlow) {
+    super(RecordingStateChangedEvent.eventName, {
+      bubbles: true,
+      composed: true,
+    });
+  }
+}
diff --git a/front_end/panels/recorder/RecorderPanel.ts b/front_end/panels/recorder/RecorderPanel.ts
new file mode 100644
index 0000000..25eea28
--- /dev/null
+++ b/front_end/panels/recorder/RecorderPanel.ts
@@ -0,0 +1,86 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as UI from '../../ui/legacy/legacy.js';
+
+import type * as Actions from './recorder-actions.js';
+import {RecorderController} from './RecorderController.js';
+
+let recorderPanelInstance: RecorderPanel;
+
+export class RecorderPanel extends UI.Panel.Panel {
+  static panelName = 'chrome_recorder';
+
+  #controller: RecorderController;
+
+  constructor() {
+    super(RecorderPanel.panelName);
+    this.#controller = new RecorderController();
+    this.contentElement.append(this.#controller);
+    this.contentElement.style.minWidth = '400px';
+  }
+
+  static instance(
+      opts: {forceNew: boolean|null} = {forceNew: null},
+      ): RecorderPanel {
+    const {forceNew} = opts;
+    if (!recorderPanelInstance || forceNew) {
+      recorderPanelInstance = new RecorderPanel();
+    }
+
+    return recorderPanelInstance;
+  }
+
+  override wasShown(): void {
+    UI.Context.Context.instance().setFlavor(RecorderPanel, this);
+    // Focus controller so shortcuts become active
+    this.#controller.focus();
+  }
+
+  override willHide(): void {
+    UI.Context.Context.instance().setFlavor(RecorderPanel, null);
+  }
+
+  handleActions(actionId: Actions.RecorderActions): void {
+    this.#controller.handleActions(actionId);
+  }
+
+  isActionPossible(actionId: Actions.RecorderActions): boolean {
+    return this.#controller.isActionPossible(actionId);
+  }
+}
+
+let recorderActionDelegateInstance: ActionDelegate;
+export class ActionDelegate implements UI.ActionRegistration.ActionDelegate {
+  static instance(
+      opts: {forceNew: boolean|null} = {forceNew: null},
+      ): ActionDelegate {
+    const {forceNew} = opts;
+    if (!recorderActionDelegateInstance || forceNew) {
+      recorderActionDelegateInstance = new ActionDelegate();
+    }
+    return recorderActionDelegateInstance;
+  }
+
+  handleAction(
+      _context: UI.Context.Context,
+      actionId: Actions.RecorderActions,
+      ): boolean {
+    void (async(): Promise<void> => {
+      await UI.ViewManager.ViewManager.instance().showView(
+          RecorderPanel.panelName,
+      );
+      const view = UI.ViewManager.ViewManager.instance().view(
+          RecorderPanel.panelName,
+      );
+
+      if (view) {
+        const widget = (await view.widget()) as RecorderPanel;
+
+        widget.handleActions(actionId);
+      }
+    })();
+    return true;
+  }
+}
diff --git a/front_end/panels/recorder/components/BUILD.gn b/front_end/panels/recorder/components/BUILD.gn
new file mode 100644
index 0000000..c2cc65f
--- /dev/null
+++ b/front_end/panels/recorder/components/BUILD.gn
@@ -0,0 +1,90 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_module.gni")
+import("../../../../scripts/build/ninja/generate_css.gni")
+
+generate_css("css_files") {
+  sources = [
+    "controlButton.css",
+    "createRecordingView.css",
+    "extensionView.css",
+    "recorderInput.css",
+    "recordingListView.css",
+    "recordingView.css",
+    "selectButton.css",
+    "startView.css",
+    "stepEditor.css",
+    "stepView.css",
+    "timelineSection.css",
+  ]
+}
+
+devtools_module("components") {
+  sources = [
+    "ControlButton.ts",
+    "CreateRecordingView.ts",
+    "ExtensionView.ts",
+    "RecorderInput.ts",
+    "RecordingListView.ts",
+    "RecordingView.ts",
+    "ReplayButton.ts",
+    "SelectButton.ts",
+    "SplitView.ts",
+    "StartView.ts",
+    "StepEditor.ts",
+    "StepView.ts",
+    "TimelineSection.ts",
+    "util.ts",
+  ]
+
+  deps = [
+    "..:actions",
+    "../../../core/common:bundle",
+    "../../../core/host:bundle",
+    "../../../core/platform:bundle",
+    "../../../core/sdk:bundle",
+    "../../../models/extensions:bundle",
+    "../../../third_party/codemirror.next:bundle",
+    "../../../ui/components/buttons:bundle",
+    "../../../ui/components/helpers:bundle",
+    "../../../ui/components/icon_button:bundle",
+    "../../../ui/components/input:bundle",
+    "../../../ui/components/panel_feedback:bundle",
+    "../../../ui/components/panel_introduction_steps:bundle",
+    "../../../ui/components/render_coordinator:bundle",
+    "../../../ui/components/text_editor:bundle",
+    "../../../ui/legacy:bundle",
+    "../../../ui/lit-html:bundle",
+    "../../../third_party/puppeteer-replay:bundle",
+    "../../../ui/components/dialogs:bundle",
+    "../../../ui/components/menus:bundle",
+    "../controllers:bundle",
+    "../converters:bundle",
+    "../extensions:bundle",
+    "../models:bundle",
+  ]
+
+  public_deps = [
+    "../images",
+  ]
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "components.ts"
+
+  deps = [
+    ":components",
+    ":css_files",
+  ]
+
+  visibility = [
+    ":*",
+    "../:*",
+    "../../../../test/unittests/front_end/panels/recorder/*",
+    "../../../ui/components/docs/*",
+  ]
+}
diff --git a/front_end/panels/recorder/components/ControlButton.ts b/front_end/panels/recorder/components/ControlButton.ts
new file mode 100644
index 0000000..d89fe27
--- /dev/null
+++ b/front_end/panels/recorder/components/ControlButton.ts
@@ -0,0 +1,52 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+
+import controlButtonStyles from './controlButton.css.js';
+
+const {html, Decorators, LitElement} = LitHtml;
+const {customElement, property} = Decorators;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-control-button': ControlButton;
+  }
+}
+
+@customElement('devtools-control-button')
+export class ControlButton extends LitElement {
+  static override styles = [controlButtonStyles];
+
+  @property() declare label: string;
+  @property() declare shape: string;
+  @property() declare disabled: boolean;
+
+  constructor() {
+    super();
+    this.label = '';
+    this.shape = 'square';
+    this.disabled = false;
+  }
+
+  #handleClickEvent = (event: Event): void => {
+    if (this.disabled) {
+      event.stopPropagation();
+      event.preventDefault();
+    }
+  };
+
+  protected override render(): unknown {
+    return html`
+            <button
+                @click=${this.#handleClickEvent}
+                .disabled=${this.disabled}
+                class="control"
+            >
+                <div class="icon ${this.shape}"></div>
+                <div class="label">${this.label}</div>
+            </button>
+        `;
+  }
+}
diff --git a/front_end/panels/recorder/components/CreateRecordingView.ts b/front_end/panels/recorder/components/CreateRecordingView.ts
new file mode 100644
index 0000000..d2416cc
--- /dev/null
+++ b/front_end/panels/recorder/components/CreateRecordingView.ts
@@ -0,0 +1,378 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/inject_checkbox_styles */
+
+import '../../../ui/legacy/legacy.js';
+
+import * as i18n from '../../../core/i18n/i18n.js';
+import * as Buttons from '../../../ui/components/buttons/buttons.js';
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as IconButton from '../../../ui/components/icon_button/icon_button.js';
+import * as Input from '../../../ui/components/input/input.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+import * as Models from '../models/models.js';
+import * as Actions from '../recorder-actions.js';  // eslint-disable-line rulesdir/es_modules_import
+
+import createRecordingViewStyles from './createRecordingView.css.js';
+
+const UIStrings = {
+  /**
+   * @description The label for the input where the user enters a name for the new recording.
+   */
+  recordingName: 'Recording name',
+  /**
+   * @description The button that start the recording with selected options.
+   */
+  startRecording: 'Start recording',
+  /**
+   * @description The title of the page that contains the form for creating a new recording.
+   */
+  createRecording: 'Create a new recording',
+  /**
+   * @description The error message that is shown if the user tries to create a recording without a name.
+   */
+  recordingNameIsRequired: 'Recording name is required',
+  /**
+   * @description The label for the input where the user enters an attribute to be used for selector generation.
+   */
+  selectorAttribute: 'Selector attribute',
+  /**
+   * @description The title for the close button where the user cancels a recording and returns back to previous view.
+   */
+  cancelRecording: 'Cancel recording',
+  /**
+   * @description Label indicating a CSS (Cascading Style Sheets) selector type
+   * (https://developer.mozilla.org/en-US/docs/Web/CSS). The label is used on a
+   * checkbox which users can tick if they are interesting in recording CSS
+   * selectors.
+   */
+  selectorTypeCSS: 'CSS',
+  /**
+   * @description Label indicating a piercing CSS (Cascading Style Sheets)
+   * selector type
+   * (https://pptr.dev/guides/query-selectors#pierce-selectors-pierce). These
+   * type of selectors behave like CSS selectors, but can pierce through
+   * ShadowDOM. The label is used on a checkbox which users can tick if they are
+   * interesting in recording CSS selectors.
+   */
+  selectorTypePierce: 'Pierce',
+  /**
+   * @description Label indicating a ARIA (Accessible Rich Internet
+   * Applications) selector type
+   * (https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA). The
+   * label is used on a checkbox which users can tick if they are interesting in
+   * recording ARIA selectors.
+   */
+  selectorTypeARIA: 'ARIA',
+  /**
+   * @description Label indicating a text selector type. The label is used on a
+   * checkbox which users can tick if they are interesting in recording text
+   * selectors.
+   */
+  selectorTypeText: 'Text',
+  /**
+   * @description Label indicating a XPath (XML Path Language) selector type
+   * (https://en.wikipedia.org/wiki/XPath). The label is used on a checkbox
+   * which users can tick if they are interesting in recording text selectors.
+   */
+  selectorTypeXPath: 'XPath',
+  /**
+   * @description The label for the input that allows specifying selector types
+   * that should be used during the recordering.
+   */
+  selectorTypes: 'Selector types to record',
+  /**
+   * @description The error message that shows up if the user turns off
+   * necessary selectors.
+   */
+  includeNecessarySelectors:
+      'You must choose CSS, Pierce, or XPath as one of your options. Only these selectors are guaranteed to be recorded since ARIA and text selectors may not be unique.',
+};
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/components/CreateRecordingView.ts',
+    UIStrings,
+);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+const closeIcon = new URL(
+                      '../images/close_icon.svg',
+                      import.meta.url,
+                      )
+                      .toString();
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-create-recording-view': CreateRecordingView;
+  }
+  interface HTMLElementEventMap {
+    recordingstarted: RecordingStartedEvent;
+    recordingcancelled: RecordingCancelledEvent;
+  }
+}
+
+export class RecordingStartedEvent extends Event {
+  static readonly eventName = 'recordingstarted';
+  name: string;
+  selectorAttribute?: string;
+  selectorTypesToRecord: Models.Schema.SelectorType[];
+
+  constructor(
+      name: string,
+      selectorTypesToRecord: Models.Schema.SelectorType[],
+      selectorAttribute?: string,
+  ) {
+    super(RecordingStartedEvent.eventName, {});
+    this.name = name;
+    this.selectorAttribute = selectorAttribute || undefined;
+    this.selectorTypesToRecord = selectorTypesToRecord;
+  }
+}
+
+export class RecordingCancelledEvent extends Event {
+  static readonly eventName = 'recordingcancelled';
+  constructor() {
+    super(RecordingCancelledEvent.eventName);
+  }
+}
+
+export interface CreateRecordingViewData {
+  recorderSettings: Models.RecorderSettings.RecorderSettings;
+}
+
+export class CreateRecordingView extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-create-recording-view`;
+  readonly #shadow = this.attachShadow({mode: 'open'});
+  #defaultRecordingName: string = '';
+  #error?: Error;
+  #recorderSettings?: Models.RecorderSettings.RecorderSettings;
+
+  connectedCallback(): void {
+    this.#shadow.adoptedStyleSheets = [
+      createRecordingViewStyles,
+      Input.textInputStyles,
+      Input.checkboxStyles,
+    ];
+    this.#render();
+    this.#shadow.querySelector('input')?.focus();
+  }
+
+  set data(data: CreateRecordingViewData) {
+    this.#recorderSettings = data.recorderSettings;
+    this.#defaultRecordingName = this.#recorderSettings.defaultTitle;
+  }
+
+  #onKeyDown(event: Event): void {
+    if (this.#error) {
+      this.#error = undefined;
+      this.#render();
+    }
+
+    const keyboardEvent = event as KeyboardEvent;
+    if (keyboardEvent.key === 'Enter') {
+      this.startRecording();
+      event.stopPropagation();
+      event.preventDefault();
+    }
+  }
+
+  startRecording(): void {
+    const nameInput = this.#shadow.querySelector(
+                          '#user-flow-name',
+                          ) as HTMLInputElement;
+    if (!nameInput) {
+      throw new Error('input#user-flow-name not found');
+    }
+    if (!this.#recorderSettings) {
+      throw new Error('settings not set');
+    }
+
+    if (!nameInput.value.trim()) {
+      this.#error = new Error(i18nString(UIStrings.recordingNameIsRequired));
+      this.#render();
+      return;
+    }
+
+    const selectorTypeElements = this.#shadow.querySelectorAll(
+        '.selector-type input[type=checkbox]',
+    );
+    const selectorTypesToRecord: Models.Schema.SelectorType[] = [];
+    for (const selectorType of selectorTypeElements) {
+      const checkbox = selectorType as HTMLInputElement;
+      const checkboxValue = checkbox.value as Models.Schema.SelectorType;
+      if (checkbox.checked) {
+        selectorTypesToRecord.push(checkboxValue);
+      }
+    }
+
+    if (!selectorTypesToRecord.includes(Models.Schema.SelectorType.CSS) &&
+        !selectorTypesToRecord.includes(Models.Schema.SelectorType.XPath) &&
+        !selectorTypesToRecord.includes(Models.Schema.SelectorType.Pierce)) {
+      this.#error = new Error(i18nString(UIStrings.includeNecessarySelectors));
+      this.#render();
+      return;
+    }
+
+    for (const selectorType of Object.values(Models.Schema.SelectorType)) {
+      this.#recorderSettings.setSelectorByType(
+          selectorType,
+          selectorTypesToRecord.includes(selectorType),
+      );
+    }
+
+    const selectorAttributeEl = this.#shadow.querySelector(
+                                    '#selector-attribute',
+                                    ) as HTMLInputElement;
+    const selectorAttribute = selectorAttributeEl.value.trim();
+    this.#recorderSettings.selectorAttribute = selectorAttribute;
+
+    this.dispatchEvent(
+        new RecordingStartedEvent(
+            nameInput.value.trim(),
+            selectorTypesToRecord,
+            selectorAttribute,
+            ),
+    );
+  }
+
+  #dispatchRecordingCancelled(): void {
+    this.dispatchEvent(new RecordingCancelledEvent());
+  }
+
+  # void => {
+    (this.#shadow.querySelector('#user-flow-name') as HTMLInputElement)?.select();
+  };
+
+  #render(): void {
+    const selectorTypeToLabel = new Map([
+      [Models.Schema.SelectorType.ARIA, i18nString(UIStrings.selectorTypeARIA)],
+      [Models.Schema.SelectorType.CSS, i18nString(UIStrings.selectorTypeCSS)],
+      [Models.Schema.SelectorType.Text, i18nString(UIStrings.selectorTypeText)],
+      [
+        Models.Schema.SelectorType.XPath,
+        i18nString(UIStrings.selectorTypeXPath),
+      ],
+      [
+        Models.Schema.SelectorType.Pierce,
+        i18nString(UIStrings.selectorTypePierce),
+      ],
+    ]);
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+        <div class="wrapper">
+          <div class="header-wrapper">
+            <h1>${i18nString(UIStrings.createRecording)}</h1>
+            <${Buttons.Button.Button.litTagName}
+              title=${i18nString(UIStrings.cancelRecording)}
+              .data=${
+                {
+                  variant: Buttons.Button.Variant.ROUND,
+                  size: Buttons.Button.Size.SMALL,
+                  iconUrl: closeIcon,
+                } as Buttons.Button.ButtonData
+              }
+              @click=${this.#dispatchRecordingCancelled}
+            ></${Buttons.Button.Button.litTagName}>
+          </div>
+          <label class="row-label" for="user-flow-name">${i18nString(
+            UIStrings.recordingName,
+          )}</label>
+          <input
+            value=${this.#defaultRecordingName}
+            @focus=${this.#onInputFocus}
+            @keydown=${this.#onKeyDown}
+            class="devtools-text-input"
+            id="user-flow-name"
+          />
+          <label class="row-label" for="selector-attribute">
+            <span>${i18nString(UIStrings.selectorAttribute)}</span>
+            <x-link class="link" href="https://g.co/devtools/recorder#selector">
+              <${IconButton.Icon.Icon.litTagName}
+                .data=${
+                  {
+                    iconName: 'help',
+                    color: 'var(--icon-default)',
+                    width: '16px',
+                    height: '16px',
+                  } as IconButton.Icon.IconData
+                }>
+              </${IconButton.Icon.Icon.litTagName}>
+            </x-link>
+          </label>
+          <input
+            value=${this.#recorderSettings?.selectorAttribute}
+            placeholder="data-testid"
+            @keydown=${this.#onKeyDown}
+            class="devtools-text-input"
+            id="selector-attribute"
+          />
+          <label class="row-label">
+            <span>${i18nString(UIStrings.selectorTypes)}</span>
+            <x-link class="link" href="https://g.co/devtools/recorder#selector">
+              <${IconButton.Icon.Icon.litTagName}
+                .data=${
+                  {
+                    iconName: 'help',
+                    color: 'var(--icon-default)',
+                    width: '16px',
+                    height: '16px',
+                  } as IconButton.Icon.IconData
+                }
+              ></${IconButton.Icon.Icon.litTagName}>
+            </x-link>
+          </label>
+          <div class="checkbox-container">
+            ${Object.values(Models.Schema.SelectorType).map(selectorType => {
+              const checked =
+                this.#recorderSettings?.getSelectorByType(selectorType);
+              return LitHtml.html`
+                  <label class="checkbox-label selector-type">
+                    <input
+                      @keydown=${this.#onKeyDown}
+                      .value=${selectorType}
+                      checked=${LitHtml.Directives.ifDefined(
+                        checked ? checked : undefined,
+                      )}
+                      type="checkbox"
+                    />
+                    ${selectorTypeToLabel.get(selectorType) || selectorType}
+                  </label>
+                `;
+            })}
+          </div>
+
+          ${
+            this.#error &&
+            LitHtml.html`
+          <div class="error" role="alert">
+            ${this.#error.message}
+          </div>
+        `
+          }
+        </div>
+        <div class="footer">
+          <div class="controls">
+            <devtools-control-button
+              @click=${this.startRecording}
+              .label=${i18nString(UIStrings.startRecording)}
+              .shape=${'circle'}
+              title=${Models.Tooltip.getTooltipForActions(
+                i18nString(UIStrings.startRecording),
+                Actions.RecorderActions.StartRecording,
+              )}
+            ></devtools-control-button>
+          </div>
+        </div>
+      `,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+  }
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-create-recording-view',
+    CreateRecordingView,
+);
diff --git a/front_end/panels/recorder/components/ExtensionView.ts b/front_end/panels/recorder/components/ExtensionView.ts
new file mode 100644
index 0000000..c583c30
--- /dev/null
+++ b/front_end/panels/recorder/components/ExtensionView.ts
@@ -0,0 +1,141 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../../../ui/legacy/legacy.js';
+
+import * as i18n from '../../../core/i18n/i18n.js';
+import * as Buttons from '../../../ui/components/buttons/buttons.js';
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+
+import type * as PublicExtensions from '../../../models/extensions/extensions.js';
+import * as Extensions from '../extensions/extensions.js';
+import * as IconButton from '../../../ui/components/icon_button/icon_button.js';
+
+import extensionViewStyles from './extensionView.css.js';
+
+const UIStrings = {
+  /**
+   * @description The button label that closes the panel that shows the extension content inside the Recorder panel.
+   */
+  closeView: 'Close',
+  /**
+   * @description The label that indicates that the content shown is provided by a browser extension.
+   */
+  extension: 'Content provided by a browser extension',
+};
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/components/ExtensionView.ts',
+    UIStrings,
+);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+const closeIcon = new URL(
+                      '../images/close_icon.svg',
+                      import.meta.url,
+                      )
+                      .toString();
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-recorder-extension-view': ExtensionView;
+  }
+  interface HTMLElementEventMap {
+    recorderextensionviewclosed: ClosedEvent;
+  }
+}
+
+export class ClosedEvent extends Event {
+  static readonly eventName = 'recorderextensionviewclosed';
+  constructor() {
+    super(ClosedEvent.eventName, {bubbles: true, composed: true});
+  }
+}
+
+const extensionIcon = new URL(
+                          '../images/extension_icon.svg',
+                          import.meta.url,
+                          )
+                          .toString();
+
+export class ExtensionView extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-recorder-extension-view`;
+  readonly #shadow = this.attachShadow({mode: 'open'});
+  #descriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor;
+
+  connectedCallback(): void {
+    this.#shadow.adoptedStyleSheets = [extensionViewStyles];
+    this.#render();
+  }
+
+  disconnectedCallback(): void {
+    if (!this.#descriptor) {
+      return;
+    }
+    Extensions.ExtensionManager.ExtensionManager.instance().getView(this.#descriptor.id).hide();
+  }
+
+  set descriptor(
+      descriptor: PublicExtensions.RecorderPluginManager.ViewDescriptor,
+  ) {
+    this.#descriptor = descriptor;
+    this.#render();
+    Extensions.ExtensionManager.ExtensionManager.instance().getView(descriptor.id).show();
+  }
+
+  #closeView(): void {
+    this.dispatchEvent(new ClosedEvent());
+  }
+
+  #render(): void {
+    if (!this.#descriptor) {
+      return;
+    }
+    const iframe = Extensions.ExtensionManager.ExtensionManager.instance().getView(this.#descriptor.id).frame();
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+        <div class="extension-view">
+          <header>
+            <div class="title">
+              <${IconButton.Icon.Icon.litTagName}
+                class="icon"
+                title=${i18nString(UIStrings.extension)}
+                .data=${
+                  {
+                    iconPath: extensionIcon,
+                    color: 'var(--color-text-secondary)',
+                  } as IconButton.Icon.IconData
+                }>
+              </${IconButton.Icon.Icon.litTagName}>
+              ${this.#descriptor.title}
+            </div>
+            <${Buttons.Button.Button.litTagName}
+              title=${i18nString(UIStrings.closeView)}
+              .data=${
+                {
+                  variant: Buttons.Button.Variant.ROUND,
+                  size: Buttons.Button.Size.TINY,
+                  iconUrl: closeIcon,
+                } as Buttons.Button.ButtonData
+              }
+              @click=${this.#closeView}
+            ></${Buttons.Button.Button.litTagName}>
+          </header>
+          <main>
+            ${iframe}
+          <main>
+      </div>
+    `,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+  }
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-recorder-extension-view',
+    ExtensionView,
+);
diff --git a/front_end/panels/recorder/components/RecorderInput.ts b/front_end/panels/recorder/components/RecorderInput.ts
new file mode 100644
index 0000000..e0372f9
--- /dev/null
+++ b/front_end/panels/recorder/components/RecorderInput.ts
@@ -0,0 +1,336 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as CodeHighlighter from '../../../ui/components/code_highlighter/code_highlighter.js';
+// eslint-disable-next-line rulesdir/es_modules_import
+import codeHighlighterStyles from
+    '../../../ui/components/code_highlighter/codeHighlighter.css.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+
+import contentEditableStyles from './recorderInput.css.js';
+import {assert, mod} from './util.js';
+
+const {html, Decorators, Directives, LitElement} = LitHtml;
+const {customElement, property, state} = Decorators;
+const {classMap} = Directives;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-recorder-input': RecorderInput;
+    'devtools-editable-content': EditableContent;
+    'devtools-suggestion-box': SuggestionBox;
+  }
+}
+
+const jsonPropertyOptions = {
+  hasChanged(value: unknown, oldValue: unknown): boolean {
+    return JSON.stringify(value) !== JSON.stringify(oldValue);
+  },
+};
+
+@customElement('devtools-editable-content')
+class EditableContent extends HTMLElement {
+  static get observedAttributes(): string[] {
+    return ['disabled', 'placeholder'];
+  }
+
+  set disabled(disabled: boolean) {
+    this.contentEditable = String(!disabled);
+  }
+
+  get disabled(): boolean {
+    return this.contentEditable !== 'true';
+  }
+
+  set value(value: string) {
+    this.innerText = value;
+    this.#highlight();
+  }
+
+  get value(): string {
+    return this.innerText;
+  }
+
+  set mimeType(type: string) {
+    this.#mimeType = type;
+    this.#highlight();
+  }
+
+  get mimeType(): string {
+    return this.#mimeType;
+  }
+
+  #mimeType = '';
+
+  constructor() {
+    super();
+
+    this.contentEditable = 'true';
+    this.tabIndex = 0;
+
+    this.addEventListener('focus', () => {
+      this.innerHTML = this.innerText;
+    });
+    this.addEventListener('blur', this.#highlight.bind(this));
+  }
+
+  #highlight(): void {
+    if (this.#mimeType) {
+      void CodeHighlighter.CodeHighlighter.highlightNode(this, this.#mimeType);
+    }
+  }
+
+  attributeChangedCallback(name: string, _: string|null, value: string|null): void {
+    switch (name) {
+      case 'disabled':
+        this.disabled = value !== null;
+        break;
+    }
+  }
+}
+
+/**
+ * Contains a suggestion emitted due to action by the user.
+ */
+class SuggestEvent extends Event {
+  static readonly eventName = 'suggest';
+  declare suggestion: string;
+  constructor(suggestion: string) {
+    super(SuggestEvent.eventName);
+    this.suggestion = suggestion;
+  }
+}
+
+/**
+ * Parents should listen for this event and register the listeners provided by
+ * this event.
+ */
+class SuggestionInitEvent extends Event {
+  static readonly eventName = 'suggestioninit';
+  listeners: [string, (event: Event) => void][];
+  constructor(listeners: [string, (event: Event) => void][]) {
+    super(SuggestionInitEvent.eventName);
+    this.listeners = listeners;
+  }
+}
+
+/**
+ * @fires SuggestionInitEvent#suggestioninit
+ * @fires SuggestEvent#suggest
+ */
+@customElement('devtools-suggestion-box')
+class SuggestionBox extends LitElement {
+  static override styles = [contentEditableStyles];
+
+  @property(jsonPropertyOptions) declare options: Readonly<string[]>;
+  @property() declare expression: string;
+
+  @state() private declare cursor: number;
+
+  #suggestions: string[] = [];
+
+  constructor() {
+    super();
+
+    this.options = [];
+    this.expression = '';
+
+    this.cursor = 0;
+  }
+
+  #handleKeyDownEvent = (event: Event): void => {
+    assert(event instanceof KeyboardEvent, 'Bound to the wrong event.');
+
+    if (this.#suggestions.length > 0) {
+      switch (event.key) {
+        case 'ArrowDown':
+          event.stopPropagation();
+          event.preventDefault();
+          this.#moveCursor(1);
+          break;
+        case 'ArrowUp':
+          event.stopPropagation();
+          event.preventDefault();
+          this.#moveCursor(-1);
+          break;
+      }
+    }
+
+    switch (event.key) {
+      case 'Enter':
+        if (this.#suggestions[this.cursor]) {
+          this.#dispatchSuggestEvent(this.#suggestions[this.cursor]);
+        }
+        event.preventDefault();
+        break;
+    }
+  };
+
+  #moveCursor(delta: number): void {
+    this.cursor = mod(this.cursor + delta, this.#suggestions.length);
+  }
+
+  #dispatchSuggestEvent(suggestion: string): void {
+    this.dispatchEvent(new SuggestEvent(suggestion));
+  }
+
+  override connectedCallback(): void {
+    super.connectedCallback();
+
+    this.dispatchEvent(
+        new SuggestionInitEvent([['keydown', this.#handleKeyDownEvent]]),
+    );
+  }
+
+  override willUpdate(changedProperties: LitHtml.PropertyValues<this>): void {
+    if (changedProperties.has('options')) {
+      this.options = Object.freeze([...this.options].sort());
+    }
+    if (changedProperties.has('expression')) {
+      this.cursor = 0;
+      this.#suggestions = this.options.filter(
+          option => option.startsWith(this.expression),
+      );
+    }
+  }
+
+  protected override render(): LitHtml.TemplateResult|undefined {
+    if (this.#suggestions.length === 0) {
+      return;
+    }
+
+    return html`<ul class="suggestions">
+      ${this.#suggestions.map((suggestion, index) => {
+      return html`<li
+          class=${classMap({
+        selected: index === this.cursor,
+      })}
+          @mousedown=${this.#dispatchSuggestEvent.bind(this, suggestion)}
+        >
+          ${suggestion}
+        </li>`;
+    })}
+    </ul>`;
+  }
+}
+
+@customElement('devtools-recorder-input')
+export class RecorderInput extends LitElement {
+  static override shadowRootOptions = {
+    ...LitElement.shadowRootOptions,
+    delegatesFocus: true,
+  } as const;
+
+  static override styles = [contentEditableStyles, codeHighlighterStyles];
+
+  /**
+   * State passed to devtools-suggestion-box.
+   */
+  @property(jsonPropertyOptions) declare options: Readonly<string[]>;
+  @state() declare expression: string;
+
+  /**
+   * State passed to devtools-editable-content.
+   */
+  @property() declare placeholder: string;
+  @property() declare value: string;
+  @property({type: Boolean}) declare disabled: boolean;
+  @property() declare mimeType: string;
+
+  constructor() {
+    super();
+
+    this.options = [];
+    this.expression = '';
+
+    this.placeholder = '';
+    this.value = '';
+    this.disabled = false;
+    this.mimeType = '';
+
+    this.addEventListener('blur', this.#handleBlurEvent);
+  }
+
+  #cachedEditableContent?: EditableContent;
+  get #editableContent(): EditableContent {
+    if (this.#cachedEditableContent) {
+      return this.#cachedEditableContent;
+    }
+    const node = this.renderRoot.querySelector('devtools-editable-content');
+    if (!node) {
+      throw new Error('Attempted to query node before rendering.');
+    }
+    this.#cachedEditableContent = node;
+    return node;
+  }
+
+  #handleBlurEvent = (): void => {
+    window.getSelection()?.removeAllRanges();
+    this.value = this.#editableContent.value;
+    this.expression = this.#editableContent.value;
+  };
+
+  #handleFocusEvent = (event: FocusEvent): void => {
+    assert(event.target instanceof Node);
+    const range = document.createRange();
+    range.selectNodeContents(event.target);
+
+    const selection = window.getSelection() as Selection;
+    selection.removeAllRanges();
+    selection.addRange(range);
+  };
+
+  #handleKeyDownEvent = (event: KeyboardEvent): void => {
+    if (event.key === 'Enter') {
+      event.preventDefault();
+    }
+  };
+
+  #handleInputEvent = (event: {target: EditableContent}): void => {
+    this.expression = event.target.value;
+  };
+
+  #handleSuggestionInitEvent = (event: SuggestionInitEvent): void => {
+    for (const [name, listener] of event.listeners) {
+      this.addEventListener(name, listener);
+    }
+  };
+
+  #handleSuggestEvent = (event: SuggestEvent): void => {
+    this.#editableContent.value = event.suggestion;
+    // If actions result in a `focus` after this blur, then the blur won't
+    // happen. `setTimeout` guarantees `blur` will always come after `focus`.
+    setTimeout(this.blur.bind(this), 0);
+  };
+
+  protected override willUpdate(
+      properties: LitHtml.PropertyValues<this>,
+      ): void {
+    if (properties.has('value')) {
+      this.expression = this.value;
+    }
+  }
+
+  protected override render(): LitHtml.TemplateResult {
+    return html`<devtools-editable-content
+        ?disabled=${this.disabled}
+        .enterKeyHint=${'done'}
+        .value=${this.value}
+        .mimeType=${this.mimeType}
+        @focus=${this.#handleFocusEvent}
+        @input=${this.#handleInputEvent}
+        @keydown=${this.#handleKeyDownEvent}
+        autocapitalize="off"
+        inputmode="text"
+        placeholder=${this.placeholder}
+        spellcheck="false"
+      ></devtools-editable-content>
+      <devtools-suggestion-box
+        @suggestioninit=${this.#handleSuggestionInitEvent}
+        @suggest=${this.#handleSuggestEvent}
+        .options=${this.options}
+        .expression=${this.expression}
+      ></devtools-suggestion-box>`;
+  }
+}
diff --git a/front_end/panels/recorder/components/RecordingListView.ts b/front_end/panels/recorder/components/RecordingListView.ts
new file mode 100644
index 0000000..d23a19c
--- /dev/null
+++ b/front_end/panels/recorder/components/RecordingListView.ts
@@ -0,0 +1,243 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as i18n from '../../../core/i18n/i18n.js';
+import * as Buttons from '../../../ui/components/buttons/buttons.js';
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as IconButton from '../../../ui/components/icon_button/icon_button.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+import * as Models from '../models/models.js';
+import * as Actions from '../recorder-actions.js';  // eslint-disable-line rulesdir/es_modules_import
+
+import recordingListViewStyles from './recordingListView.css.js';
+
+const UIStrings = {
+  /**
+   *@description The title of the page that contains a list of saved recordings that the user has..
+   */
+  savedRecordings: 'Saved recordings',
+  /**
+   * @description The title of the button that leads to create a new recording page.
+   */
+  createRecording: 'Create a new recording',
+  /**
+   * @description The title of the button that is shown next to each of the recordings and that triggers playing of the recording.
+   */
+  playRecording: 'Play recording',
+  /**
+   * @description The title of the button that is shown next to each of the recordings and that triggers deletion of the recording.
+   */
+  deleteRecording: 'Delete recording',
+  /**
+   * @description The title of the row corresponding to a recording. By clicking on the row, the user open the recording for editing.
+   */
+  openRecording: 'Open recording',
+};
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/components/RecordingListView.ts',
+    UIStrings,
+);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-recording-list-view': RecordingListView;
+  }
+
+  interface HTMLElementEventMap {
+    openrecording: OpenRecordingEvent;
+    deleterecording: DeleteRecordingEvent;
+  }
+}
+
+export class CreateRecordingEvent extends Event {
+  static readonly eventName = 'createrecording';
+  constructor() {
+    super(CreateRecordingEvent.eventName);
+  }
+}
+
+export class DeleteRecordingEvent extends Event {
+  static readonly eventName = 'deleterecording';
+  constructor(public storageName: string) {
+    super(DeleteRecordingEvent.eventName);
+  }
+}
+
+export class OpenRecordingEvent extends Event {
+  static readonly eventName = 'openrecording';
+  constructor(public storageName: string) {
+    super(OpenRecordingEvent.eventName);
+  }
+}
+
+export class PlayRecordingEvent extends Event {
+  static readonly eventName = 'playrecording';
+  constructor(public storageName: string) {
+    super(PlayRecordingEvent.eventName);
+  }
+}
+
+interface Recording {
+  storageName: string;
+  name: string;
+}
+
+const pathIconUrl = new URL(
+                        '../images/path_icon.svg',
+                        import.meta.url,
+                        )
+                        .toString();
+const playIconUrl = new URL(
+                        '../images/play_icon.svg',
+                        import.meta.url,
+                        )
+                        .toString();
+const deleteIconUrl = new URL(
+                          '../images/delete_icon.svg',
+                          import.meta.url,
+                          )
+                          .toString();
+export class RecordingListView extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-recording-list-view`;
+  readonly #shadow = this.attachShadow({mode: 'open'});
+  readonly #props: {recordings: Recording[], replayAllowed: boolean} = {
+    recordings: [],
+    replayAllowed: true,
+  };
+
+  connectedCallback(): void {
+    this.#shadow.adoptedStyleSheets = [recordingListViewStyles];
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  set recordings(recordings: Recording[]) {
+    this.#props.recordings = recordings;
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  set replayAllowed(value: boolean) {
+    this.#props.replayAllowed = value;
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  #onCreateClick(): void {
+    this.dispatchEvent(new CreateRecordingEvent());
+  }
+
+  #onDeleteClick(storageName: string, event: Event): void {
+    event.stopPropagation();
+    this.dispatchEvent(new DeleteRecordingEvent(storageName));
+  }
+
+  #onOpenClick(storageName: string, event: Event): void {
+    event.stopPropagation();
+    this.dispatchEvent(new OpenRecordingEvent(storageName));
+  }
+
+  #onPlayRecordingClick(storageName: string, event: Event): void {
+    event.stopPropagation();
+    this.dispatchEvent(new PlayRecordingEvent(storageName));
+  }
+
+  #onKeyDown(storageName: string, event: Event): void {
+    if ((event as KeyboardEvent).key !== 'Enter') {
+      return;
+    }
+    this.#onOpenClick(storageName, event);
+  }
+
+  #stopPropagation(event: Event): void {
+    event.stopPropagation();
+  }
+
+  #render = (): void => {
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+        <div class="wrapper">
+          <div class="header">
+            <h1>${i18nString(UIStrings.savedRecordings)}</h1>
+            <${Buttons.Button.Button.litTagName}
+              .variant=${Buttons.Button.Variant.PRIMARY}
+              @click=${this.#onCreateClick}
+              title=${Models.Tooltip.getTooltipForActions(
+                i18nString(UIStrings.createRecording),
+                Actions.RecorderActions.CreateRecording,
+              )}
+            >
+              ${i18nString(UIStrings.createRecording)}
+            </${Buttons.Button.Button.litTagName}>
+          </div>
+          <div class="table">
+            ${this.#props.recordings.map(recording => {
+              return LitHtml.html`
+                  <div role="button" tabindex="0" aria-label=${i18nString(
+                    UIStrings.openRecording,
+                  )} class="row" @keydown=${this.#onKeyDown.bind(
+                this,
+                recording.storageName,
+              )} @click=${this.#onOpenClick.bind(this, recording.storageName)}>
+                    <div class="icon">
+                      <${IconButton.Icon.Icon.litTagName} .data=${
+                {
+                  iconPath: pathIconUrl,
+                  color: 'var(--color-primary-old)',
+                } as IconButton.Icon.IconData
+              }>
+                      </${IconButton.Icon.Icon.litTagName}>
+                    </div>
+                    <div class="title">${recording.name}</div>
+                    <div class="actions">
+                      ${
+                        this.#props.replayAllowed
+                          ? LitHtml.html`<button title=${i18nString(
+                              UIStrings.playRecording,
+                            )} @keydown=${
+                              this.#stopPropagation
+                            } @click=${this.#onPlayRecordingClick.bind(
+                              this,
+                              recording.storageName,
+                            )}>
+                        <${IconButton.Icon.Icon.litTagName} .data=${
+                              {
+                                iconPath: playIconUrl,
+                                color: 'var(--color-text-primary)',
+                              } as IconButton.Icon.IconData
+                            }>
+                        </${IconButton.Icon.Icon.litTagName}>
+                      </button><div class="divider"></div>`
+                          : ''
+                      }
+                      <button class="delete-recording-button" title=${i18nString(
+                        UIStrings.deleteRecording,
+                      )} @keydown=${
+                this.#stopPropagation
+              } @click=${this.#onDeleteClick.bind(this, recording.storageName)}>
+                        <${IconButton.Icon.Icon.litTagName} .data=${
+                {
+                  iconPath: deleteIconUrl,
+                  color: 'var(--color-text-primary)',
+                } as IconButton.Icon.IconData
+              }>
+                        </${IconButton.Icon.Icon.litTagName}>
+                      </button>
+                    </div>
+                  </div>
+                `;
+            })}
+          </div>
+        </div>
+      `,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+  };
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-recording-list-view',
+    RecordingListView,
+);
diff --git a/front_end/panels/recorder/components/RecordingView.ts b/front_end/panels/recorder/components/RecordingView.ts
new file mode 100644
index 0000000..f403e83
--- /dev/null
+++ b/front_end/panels/recorder/components/RecordingView.ts
@@ -0,0 +1,1375 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Host from '../../../core/host/host.js';
+import * as i18n from '../../../core/i18n/i18n.js';
+import * as Platform from '../../../core/platform/platform.js';
+import * as SDK from '../../../core/sdk/sdk.js';
+import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js';
+import * as Buttons from '../../../ui/components/buttons/buttons.js';
+import * as CodeHighlighter from '../../../ui/components/code_highlighter/code_highlighter.js';
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as IconButton from '../../../ui/components/icon_button/icon_button.js';
+import * as Input from '../../../ui/components/input/input.js';
+import * as TextEditor from '../../../ui/components/text_editor/text_editor.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+import * as Dialogs from '../../../ui/components/dialogs/dialogs.js';
+import * as Menus from '../../../ui/components/menus/menus.js';
+
+import type * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+import type * as PublicExtensions from '../../../models/extensions/extensions.js';
+
+import type * as Extensions from '../extensions/extensions.js';
+import type * as Converters from '../converters/converters.js';
+import * as Models from '../models/models.js';
+import * as Actions from '../recorder-actions.js';  // eslint-disable-line rulesdir/es_modules_import
+
+import recordingViewStyles from './recordingView.css.js';
+
+import {
+  State,
+  StepView,
+  type StepViewData,
+  type CopyStepEvent,
+} from './StepView.js';
+
+import {PlayRecordingSpeed} from '../models/RecordingPlayer.js';
+import {
+  ReplayButton,
+  type ReplayButtonData,
+  type StartReplayEvent,
+} from './ReplayButton.js';
+import {SplitView} from './SplitView.js';
+import {ExtensionView} from './ExtensionView.js';
+
+const UIStrings = {
+  /**
+   * @description Depicts that the recording was done on a mobile device (e.g., a smartphone or tablet).
+   */
+  mobile: 'Mobile',
+  /**
+   * @description Depicts that the recording was done on a desktop device (e.g., on a PC or laptop).
+   */
+  desktop: 'Desktop',
+  /**
+   * @description Network latency in milliseconds.
+   * @example {10} value
+   */
+  latency: 'Latency: {value} ms',
+  /**
+   * @description Upload speed.
+   * @example {42 kB} value
+   */
+  upload: 'Upload: {value}',
+  /**
+   * @description Download speed.
+   * @example {8 kB} value
+   */
+  download: 'Download: {value}',
+  /**
+   * @description Title of the button to edit replay settings.
+   */
+  editReplaySettings: 'Edit replay settings',
+  /**
+   * @description Title of the section that contains replay settings.
+   */
+  replaySettings: 'Replay settings',
+  /**
+   * @description The string is shown when a default value is used for some replay settings.
+   */
+  default: 'Default',
+  /**
+   * @description The title of the section with environment settings.
+   */
+  environment: 'Environment',
+  /**
+   * @description The title of the screenshot image that is shown for every section in the recordign view.
+   */
+  screenshotForSection: 'Screenshot for this section',
+  /**
+   * @description The title of the button that edits the current recording's title.
+   */
+  editTitle: 'Edit title',
+  /**
+   * @description The error for when the title is missing.
+   */
+  requiredTitleError: 'Title is required',
+  /**
+   * @description The status text that is shown while the recording is ongoing.
+   */
+  recording: 'Recording…',
+  /**
+   * @description The title of the button to end the current recording.
+   */
+  endRecording: 'End recording',
+  /**
+   * @description The title of the button while the recording is being ended.
+   */
+  recordingIsBeingStopped: 'Stopping recording…',
+  /**
+   * @description The text that describes a timeout setting of {value} milliseconds.
+   * @example {1000} value
+   */
+  timeout: 'Timeout: {value} ms',
+  /**
+   * @description The label for the input that allows entering network throttling configuration.
+   */
+  network: 'Network',
+  /**
+   * @description The label for the input that allows entering timeout (a number in ms) configuration.
+   */
+  timeoutLabel: 'Timeout',
+  /**
+   * @description The text in a tooltip for the timeout input that explains what timeout settings do.
+   */
+  timeoutExplanation:
+      'The timeout setting (in milliseconds) applies to every action when replaying the recording. For example, if a DOM element identified by a CSS selector does not appear on the page within the specified timeout, the replay fails with an error.',
+  /**
+   * @description The label for the button that cancels replaying.
+   */
+  cancelReplay: 'Cancel replay',
+  /**
+   * @description Button title that shows the code view when clicked.
+   */
+  showCode: 'Show code',
+  /**
+   * @description Button title that hides the code view when clicked.
+   */
+  hideCode: 'Hide code',
+  /**
+   * @description Button title that adds an assertion to the step editor.
+   */
+  addAssertion: 'Add assertion',
+  /**
+   * @description The title of the button that open current recording in Performance panel.
+   */
+  performancePanel: 'Performance panel',
+};
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/components/RecordingView.ts',
+    UIStrings,
+);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+const deployMenuArrow = new URL(
+                            '../images/select-arrow-icon.svg',
+                            import.meta.url,
+                            )
+                            .toString();
+const abortIconUrl = new URL(
+                         '../images/abort_icon.svg',
+                         import.meta.url,
+                         )
+                         .toString();
+const closeIcon = new URL(
+                      '../images/close_icon.svg',
+                      import.meta.url,
+                      )
+                      .toString();
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-recording-view': RecordingView;
+  }
+}
+
+export interface ReplayState {
+  isPlaying: boolean;             // Replay is in progress
+  isPausedOnBreakpoint: boolean;  // Replay is in progress and is in stopped state
+}
+
+export interface RecordingViewData {
+  replayState: ReplayState;
+  isRecording: boolean;
+  recordingTogglingInProgress: boolean;
+  recording: Models.Schema.UserFlow;
+  currentStep?: Models.Schema.Step;
+  currentError?: Error;
+  sections: Models.Section.Section[];
+  settings?: Models.RecordingSettings.RecordingSettings;
+  recorderSettings?: Models.RecorderSettings.RecorderSettings;
+  lastReplayResult?: Models.RecordingPlayer.ReplayResult;
+  replayAllowed: boolean;
+  breakpointIndexes: Set<number>;
+  builtInConverters: Converters.Converter.Converter[];
+  extensionConverters: Converters.Converter.Converter[];
+  replayExtensions: Extensions.ExtensionManager.Extension[];
+  extensionDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor;
+}
+
+export class RecordingFinishedEvent extends Event {
+  static readonly eventName = 'recordingfinished';
+
+  constructor() {
+    super(RecordingFinishedEvent.eventName);
+  }
+}
+
+export const enum TargetPanel {
+  PerformancePanel = 'timeline',
+  Default = 'chrome_recorder',
+}
+
+interface PlayRecordingEventData {
+  targetPanel: TargetPanel;
+  speed: PlayRecordingSpeed;
+  extension?: Extensions.ExtensionManager.Extension;
+}
+
+export class PlayRecordingEvent extends Event {
+  static readonly eventName = 'playrecording';
+  readonly data: PlayRecordingEventData;
+  constructor(
+      data: PlayRecordingEventData = {
+        targetPanel: TargetPanel.Default,
+        speed: PlayRecordingSpeed.Normal,
+      },
+  ) {
+    super(PlayRecordingEvent.eventName);
+    this.data = data;
+  }
+}
+
+export class AbortReplayEvent extends Event {
+  static readonly eventName = 'abortreplay';
+  constructor() {
+    super(AbortReplayEvent.eventName);
+  }
+}
+
+export class RecordingChangedEvent extends Event {
+  static readonly eventName = 'recordingchanged';
+  data: {currentStep: Models.Schema.Step, newStep: Models.Schema.Step};
+  constructor(currentStep: Models.Schema.Step, newStep: Models.Schema.Step) {
+    super(RecordingChangedEvent.eventName);
+    this.data = {currentStep, newStep};
+  }
+}
+
+export class AddAssertionEvent extends Event {
+  static readonly eventName = 'addassertion';
+  constructor() {
+    super(AddAssertionEvent.eventName);
+  }
+}
+
+export class RecordingTitleChangedEvent extends Event {
+  static readonly eventName = 'recordingtitlechanged';
+  title: string;
+
+  constructor(title: string) {
+    super(RecordingTitleChangedEvent.eventName, {});
+    this.title = title;
+  }
+}
+
+export class NetworkConditionsChanged extends Event {
+  static readonly eventName = 'networkconditionschanged';
+  data?: SDK.NetworkManager.Conditions;
+  constructor(data?: SDK.NetworkManager.Conditions) {
+    super(NetworkConditionsChanged.eventName, {
+      composed: true,
+      bubbles: true,
+    });
+    this.data = data;
+  }
+}
+
+export class TimeoutChanged extends Event {
+  static readonly eventName = 'timeoutchanged';
+  data?: number;
+  constructor(data?: number) {
+    super(TimeoutChanged.eventName, {composed: true, bubbles: true});
+    this.data = data;
+  }
+}
+
+const editIconUrl = new URL(
+                        '../images/edit_icon.svg',
+                        import.meta.url,
+                        )
+                        .toString();
+const throttlingIconUrl = new URL(
+                              '../images/throttling_icon.svg',
+                              import.meta.url,
+                              )
+                              .toString();
+
+const networkConditionPresets = [
+  SDK.NetworkManager.NoThrottlingConditions,
+  SDK.NetworkManager.OfflineConditions,
+  SDK.NetworkManager.Slow3GConditions,
+  SDK.NetworkManager.Fast3GConditions,
+];
+
+function converterIdToFlowMetric(
+    converterId: string,
+    ): Host.UserMetrics.RecordingCopiedToClipboard {
+  switch (converterId) {
+    case Models.ConverterIds.ConverterIds.Puppeteer:
+      return Host.UserMetrics.RecordingCopiedToClipboard.CopiedRecordingWithPuppeteer;
+    case Models.ConverterIds.ConverterIds.JSON:
+      return Host.UserMetrics.RecordingCopiedToClipboard.CopiedRecordingWithJSON;
+    case Models.ConverterIds.ConverterIds.Replay:
+      return Host.UserMetrics.RecordingCopiedToClipboard.CopiedRecordingWithReplay;
+    default:
+      return Host.UserMetrics.RecordingCopiedToClipboard.CopiedRecordingWithExtension;
+  }
+}
+
+function converterIdToStepMetric(
+    converterId: string,
+    ): Host.UserMetrics.RecordingCopiedToClipboard {
+  switch (converterId) {
+    case Models.ConverterIds.ConverterIds.Puppeteer:
+      return Host.UserMetrics.RecordingCopiedToClipboard.CopiedStepWithPuppeteer;
+    case Models.ConverterIds.ConverterIds.JSON:
+      return Host.UserMetrics.RecordingCopiedToClipboard.CopiedStepWithJSON;
+    case Models.ConverterIds.ConverterIds.Replay:
+      return Host.UserMetrics.RecordingCopiedToClipboard.CopiedStepWithReplay;
+    default:
+      return Host.UserMetrics.RecordingCopiedToClipboard.CopiedStepWithExtension;
+  }
+}
+
+export class RecordingView extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-recording-view`;
+
+  readonly #shadow = this.attachShadow({mode: 'open'});
+  #replayState: ReplayState = {isPlaying: false, isPausedOnBreakpoint: false};
+  #userFlow: Models.Schema.UserFlow|null = null;
+  #isRecording: boolean = false;
+  #recordingTogglingInProgress: boolean = false;
+  #titleInputFocused: boolean = false;
+  #titleInputChanged: boolean = true;
+  #titleInputWidth: number = 0;
+  #titleInput?: HTMLInputElement;
+  #currentStep?: Models.Schema.Step;
+  #steps: Models.Schema.Step[] = [];
+  #currentError?: Error;
+  #sections: Models.Section.Section[] = [];
+  #settings?: Models.RecordingSettings.RecordingSettings;
+  #recorderSettings?: Models.RecorderSettings.RecorderSettings;
+  #lastReplayResult?: Models.RecordingPlayer.ReplayResult;
+  #breakpointIndexes: Set<number> = new Set();
+  #selectedStep?: Models.Schema.Step|null;
+
+  #replaySettingsExpanded = false;
+  #replayAllowed = true;
+  #builtInConverters: Converters.Converter.Converter[] = [];
+  #extensionConverters: Converters.Converter.Converter[] = [];
+  #replayExtensions?: Extensions.ExtensionManager.Extension[];
+  #showCodeView = false;
+  #code: string = '';
+  #converterId: string = '';
+  #editorState?: CodeMirror.EditorState;
+  #sourceMap: PuppeteerReplay.SourceMap|undefined;
+  #extensionDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor;
+
+  #>
+
+  set data(data: RecordingViewData) {
+    this.#isRecording = data.isRecording;
+    this.#replayState = data.replayState;
+    this.#recordingTogglingInProgress = data.recordingTogglingInProgress;
+    this.#currentStep = data.currentStep;
+
+    this.#userFlow = data.recording;
+    this.#steps = this.#userFlow.steps;
+    this.#sections = data.sections;
+    this.#settings = data.settings;
+    this.#recorderSettings = data.recorderSettings;
+
+    this.#currentError = data.currentError;
+    this.#lastReplayResult = data.lastReplayResult;
+    this.#replayAllowed = data.replayAllowed;
+    this.#titleInputChanged = true;
+    this.#breakpointIndexes = data.breakpointIndexes;
+    this.#builtInConverters = data.builtInConverters;
+    this.#extensionConverters = data.extensionConverters;
+    this.#replayExtensions = data.replayExtensions;
+    this.#extensionDescriptor = data.extensionDescriptor;
+
+    this.#converterId = this.#recorderSettings?.preferredCopyFormat ?? data.builtInConverters[0]?.getId();
+    void this.#convertToCode();
+
+    this.#render();
+  }
+
+  connectedCallback(): void {
+    this.#shadow.adoptedStyleSheets = [
+      recordingViewStyles,
+      Input.textInputStyles,
+    ];
+    document.addEventListener('copy', this.#onCopyBound);
+    this.#render();
+  }
+
+  disconnectedCallback(): void {
+    document.removeEventListener('copy', this.#onCopyBound);
+  }
+
+  scrollToBottom(): void {
+    const wrapper = this.shadowRoot?.querySelector('.sections');
+    if (!wrapper) {
+      return;
+    }
+    wrapper.scrollTop = wrapper.scrollHeight;
+  }
+
+  #measureTitleInput(): void {
+    const calculator = (this.shadowRoot as ShadowRoot)
+                           .getElementById(
+                               'title-input-label',
+                               ) as HTMLDivElement;
+    this.#titleInputWidth = calculator.offsetWidth;
+  }
+
+  #dispatchAddAssertionEvent(): void {
+    this.dispatchEvent(new AddAssertionEvent());
+  }
+
+  #dispatchRecordingFinished(): void {
+    this.dispatchEvent(new RecordingFinishedEvent());
+  }
+
+  #handleAbortReplay(): void {
+    this.dispatchEvent(new AbortReplayEvent());
+  }
+
+  #handleTogglePlaying(event: StartReplayEvent): void {
+    this.dispatchEvent(
+        new PlayRecordingEvent({
+          targetPanel: TargetPanel.Default,
+          speed: event.speed,
+          extension: event.extension,
+        }),
+    );
+  }
+
+  #getStepState(step: Models.Schema.Step): State {
+    if (!this.#currentStep) {
+      return State.Default;
+    }
+    if (step === this.#currentStep) {
+      if (this.#currentError) {
+        return State.Error;
+      }
+      if (!this.#replayState.isPlaying) {
+        return State.Success;
+      }
+
+      if (this.#replayState.isPausedOnBreakpoint) {
+        return State.Stopped;
+      }
+
+      return State.Current;
+    }
+    const currentIndex = this.#steps.indexOf(this.#currentStep);
+    if (currentIndex === -1) {
+      return State.Default;
+    }
+
+    const index = this.#steps.indexOf(step);
+    return index < currentIndex ? State.Success : State.Outstanding;
+  }
+
+  #getSectionState(section: Models.Section.Section): State {
+    const currentStep = this.#currentStep;
+    if (!currentStep) {
+      return State.Default;
+    }
+
+    const currentSection = this.#sections.find(
+                               section => section.steps.includes(currentStep),
+                               ) as Models.Section.Section;
+
+    if (!currentSection) {
+      if (this.#currentError) {
+        return State.Error;
+      }
+    }
+
+    if (section === currentSection) {
+      return State.Success;
+    }
+
+    const index = this.#sections.indexOf(currentSection);
+    const ownIndex = this.#sections.indexOf(section);
+    return index >= ownIndex ? State.Success : State.Outstanding;
+  }
+
+  #renderStep(
+      section: Models.Section.Section,
+      step: Models.Schema.Step,
+      isLastSection: boolean,
+      ): LitHtml.TemplateResult {
+    const stepIndex = this.#steps.indexOf(step);
+    // clang-format off
+    return LitHtml.html`
+      <${StepView.litTagName}
+      @click=${this.#onStepClick}
+      @mouseover=${this.#onStepHover}
+      @copystep=${this.#onCopyStepEvent}
+      .data=${
+        {
+          step,
+          state: this.#getStepState(step),
+          error: this.#currentStep === step ? this.#currentError : undefined,
+          isFirstSection: false,
+          isLastSection:
+            isLastSection && this.#steps[this.#steps.length - 1] === step,
+          isStartOfGroup: false,
+          isEndOfGroup: section.steps[section.steps.length - 1] === step,
+          stepIndex,
+          hasBreakpoint: this.#breakpointIndexes.has(stepIndex),
+          sectionIndex: -1,
+          isRecording: this.#isRecording,
+          isPlaying: this.#replayState.isPlaying,
+          removable: this.#steps.length > 1,
+          builtInConverters: this.#builtInConverters,
+          extensionConverters: this.#extensionConverters,
+          isSelected: this.#selectedStep === step,
+          recorderSettings: this.#recorderSettings,
+        } as StepViewData
+      }
+      ></${StepView.litTagName}>
+    `;
+    // clang-format on
+  }
+
+  # MouseEvent): void => {
+    const stepView = event.target as StepView;
+    const step = stepView.step || stepView.section?.causingStep;
+    if (!step || this.#selectedStep) {
+      return;
+    }
+    this.#highlightCodeForStep(step);
+  };
+
+  #onStepClick(event: Event): void {
+    event.stopPropagation();
+    const stepView = event.target as StepView;
+    const selectedStep = stepView.step || stepView.section?.causingStep || null;
+    if (this.#selectedStep === selectedStep) {
+      return;
+    }
+    this.#selectedStep = selectedStep;
+    this.#render();
+    if (selectedStep) {
+      this.#highlightCodeForStep(selectedStep, /* scroll=*/ true);
+    }
+  }
+
+  #onWrapperClick(): void {
+    if (this.#selectedStep === undefined) {
+      return;
+    }
+    this.#selectedStep = undefined;
+    this.#render();
+  }
+
+  #onReplaySettingsKeydown(event: Event): void {
+    if ((event as KeyboardEvent).key !== 'Enter') {
+      return;
+    }
+    event.preventDefault();
+    this.#onToggleReplaySettings(event);
+  }
+
+  #onToggleReplaySettings(event: Event): void {
+    event.stopPropagation();
+    this.#replaySettingsExpanded = !this.#replaySettingsExpanded;
+    this.#render();
+  }
+
+  #onNetworkConditionsChange(event: Menus.SelectMenu.SelectMenuItemSelectedEvent): void {
+    const preset = networkConditionPresets.find(
+        preset => preset.i18nTitleKey === event.itemValue,
+    );
+    this.dispatchEvent(
+        new NetworkConditionsChanged(
+            preset?.i18nTitleKey === SDK.NetworkManager.NoThrottlingConditions.i18nTitleKey ? undefined : preset,
+            ),
+    );
+  }
+
+  #onTimeoutInput(event: Event): void {
+    const target = event.target as HTMLInputElement;
+    if (!target.checkValidity()) {
+      target.reportValidity();
+      return;
+    }
+    this.dispatchEvent(new TimeoutChanged(Number(target.value)));
+  }
+
+  # InputEvent): void => {
+    const target = event.target as HTMLInputElement;
+    if (!target.value) {
+      return;
+    }
+    target.setCustomValidity('');
+    this.#titleInputChanged = true;
+    this.dispatchEvent(new RecordingTitleChangedEvent(target.value));
+  };
+
+  # KeyboardEvent): void => {
+    switch (event.code) {
+      case 'Escape':
+      case 'Enter':
+        (event.target as HTMLInputElement).blur();
+        event.stopPropagation();
+        break;
+    }
+  };
+
+  # void => {
+    this.#titleInputFocused = true;
+    this.#render();
+  };
+
+  # Event): void => {
+    const target = event.target as HTMLInputElement;
+    if (!target.checkValidity()) {
+      this.#titleInput = target;
+    }
+    this.#titleInputFocused = false;
+    this.#render();
+  };
+
+  # void => {
+    (this.#shadow.getElementById('title-input') as HTMLInputElement).focus();
+  };
+
+  #isTitleInputValid = (): boolean => {
+    return !this.#titleInput || this.#titleInput.validity.valid;
+  };
+
+  # Event): void => {
+    const target = event.target as HTMLElement;
+    if (target.matches('.wrapping-label')) {
+      target.querySelector('devtools-select-menu')?.click();
+    }
+  };
+
+  async #copyCurrentSelection(step?: Models.Schema.Step|null): Promise<void> {
+    let converter =
+        [
+          ...this.#builtInConverters,
+          ...this.#extensionConverters,
+        ]
+            .find(
+                converter => converter.getId() === this.#recorderSettings?.preferredCopyFormat,
+            );
+    if (!converter) {
+      converter = this.#builtInConverters[0];
+    }
+    if (!converter) {
+      throw new Error('No default converter found');
+    }
+
+    let text = '';
+    if (step) {
+      text = await converter.stringifyStep(step);
+    } else if (this.#userFlow) {
+      [text] = await converter.stringify(this.#userFlow);
+    }
+
+    Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(text);
+    const metric = step ? converterIdToStepMetric(converter.getId()) : converterIdToFlowMetric(converter.getId());
+    Host.userMetrics.recordingCopiedToClipboard(metric);
+  }
+
+  #onCopyStepEvent(event: CopyStepEvent): void {
+    event.stopPropagation();
+    void this.#copyCurrentSelection(event.step);
+  }
+
+  async #onCopy(event: ClipboardEvent): Promise<void> {
+    if (event.target !== document.body) {
+      return;
+    }
+
+    event.preventDefault();
+    await this.#copyCurrentSelection(this.#selectedStep);
+    Host.userMetrics.keyboardShortcutFired(
+        'chrome_recorder.copy-recording-or-step',
+    );
+  }
+
+  #renderSettings(): LitHtml.TemplateResult {
+    if (!this.#settings) {
+      return LitHtml.html``;
+    }
+    const environmentFragments = [];
+    if (this.#settings.viewportSettings) {
+      // clang-format off
+      environmentFragments.push(
+        LitHtml.html`<div>${
+          this.#settings.viewportSettings.isMobile
+            ? i18nString(UIStrings.mobile)
+            : i18nString(UIStrings.desktop)
+        }</div>`,
+      );
+      environmentFragments.push(LitHtml.html`<div class="separator"></div>`);
+      environmentFragments.push(
+        LitHtml.html`<div>${this.#settings.viewportSettings.width}×${
+          this.#settings.viewportSettings.height
+        } px</div>`,
+      );
+      // clang-format on
+    }
+    const replaySettingsFragments = [];
+    if (!this.#replaySettingsExpanded) {
+      if (this.#settings.networkConditionsSettings) {
+        if (this.#settings.networkConditionsSettings.title) {
+          // clang-format off
+          replaySettingsFragments.push(
+            LitHtml.html`<div>${
+              this.#settings.networkConditionsSettings.title
+            }</div>`,
+          );
+          // clang-format on
+        } else {
+          // clang-format off
+          replaySettingsFragments.push(LitHtml.html`<div>
+            ${i18nString(UIStrings.download, {
+              value: Platform.NumberUtilities.bytesToString(
+                this.#settings.networkConditionsSettings.download,
+              ),
+            })},
+            ${i18nString(UIStrings.upload, {
+              value: Platform.NumberUtilities.bytesToString(
+                this.#settings.networkConditionsSettings.upload,
+              ),
+            })},
+            ${i18nString(UIStrings.latency, {
+              value: this.#settings.networkConditionsSettings.latency,
+            })}
+          </div>`);
+          // clang-format on
+        }
+      } else {
+        // clang-format off
+        replaySettingsFragments.push(
+          LitHtml.html`<div>${
+            SDK.NetworkManager.NoThrottlingConditions.title instanceof Function
+              ? SDK.NetworkManager.NoThrottlingConditions.title()
+              : SDK.NetworkManager.NoThrottlingConditions.title
+          }</div>`,
+        );
+        // clang-format on
+      }
+      // clang-format off
+      replaySettingsFragments.push(LitHtml.html`<div class="separator"></div>`);
+      replaySettingsFragments.push(
+        LitHtml.html`<div>${i18nString(UIStrings.timeout, {
+          value: this.#settings.timeout || Models.RecordingPlayer.defaultTimeout,
+        })}</div>`,
+      );
+      // clang-format on
+    } else {
+      // clang-format off
+      const selectedOption =
+        this.#settings.networkConditionsSettings?.i18nTitleKey ||
+        SDK.NetworkManager.NoThrottlingConditions.i18nTitleKey;
+      const selectedOptionTitle = networkConditionPresets.find(
+        preset => preset.i18nTitleKey === selectedOption,
+      );
+      let menuButtonTitle = '';
+      if (selectedOptionTitle) {
+        menuButtonTitle =
+          selectedOptionTitle.title instanceof Function
+            ? selectedOptionTitle.title()
+            : selectedOptionTitle.title;
+      }
+
+      replaySettingsFragments.push(LitHtml.html`<div class="editable-setting">
+        <label class="wrapping-label" @click=${this.#onSelectMenuLabelClick}>
+          ${i18nString(UIStrings.network)}
+          <${Menus.SelectMenu.SelectMenu.litTagName}
+            @selectmenuselected=${this.#onNetworkConditionsChange}
+            .disabled=${!this.#steps.find(step => step.type === 'navigate')}
+            .showDivider=${true}
+            .showArrow=${true}
+            .sideButton=${false}
+            .showSelectedItem=${true}
+            .showConnector=${false}
+            .position=${Dialogs.Dialog.DialogVerticalPosition.BOTTOM}
+            .buttonTitle=${menuButtonTitle}
+          >
+            ${networkConditionPresets.map(condition => {
+              return LitHtml.html`<${Menus.Menu.MenuItem.litTagName}
+                .value=${condition.i18nTitleKey}
+                .selected=${selectedOption === condition.i18nTitleKey}
+              >
+                ${
+                  condition.title instanceof Function
+                    ? condition.title()
+                    : condition.title
+                }
+              </${Menus.Menu.MenuItem.litTagName}>`;
+            })}
+          </${Menus.SelectMenu.SelectMenu.litTagName}>
+        </label>
+      </div>`);
+      replaySettingsFragments.push(LitHtml.html`<div class="editable-setting">
+        <label class="wrapping-label" title=${i18nString(
+          UIStrings.timeoutExplanation,
+        )}>
+          ${i18nString(UIStrings.timeoutLabel)}
+          <input
+            @input=${this.#onTimeoutInput}
+            required
+            min=${Models.SchemaUtils.minTimeout}
+            max=${Models.SchemaUtils.maxTimeout}
+            value=${
+              this.#settings.timeout || Models.RecordingPlayer.defaultTimeout
+            }
+            class="devtools-text-input"
+            type="number">
+        </label>
+      </div>`);
+      // clang-format on
+    }
+    const isEditable = !this.#isRecording && !this.#replayState.isPlaying;
+    const replaySettingsButtonClassMap = {
+      'settings-title': true,
+      expanded: this.#replaySettingsExpanded,
+    };
+    const replaySettingsClassMap = {
+      expanded: this.#replaySettingsExpanded,
+      settings: true,
+    };
+    // clang-format off
+    return LitHtml.html`
+      <div class="settings-row">
+        <div class="settings-container">
+          <div
+            class=${LitHtml.Directives.classMap(replaySettingsButtonClassMap)}
+            @keydown=${isEditable && this.#onReplaySettingsKeydown}
+            @click=${isEditable && this.#onToggleReplaySettings}
+            tabindex="0"
+            role="button"
+            aria-label=${i18nString(UIStrings.editReplaySettings)}>
+            <span>${i18nString(UIStrings.replaySettings)}</span>
+            ${
+              isEditable
+                ? LitHtml.html`<${IconButton.Icon.Icon.litTagName}
+              class="chevron"
+              .data=${
+                {
+                  iconPath: deployMenuArrow,
+                  color: 'var(--color-text-primary)',
+                } as IconButton.Icon.IconData
+              }>
+            </${IconButton.Icon.Icon.litTagName}>`
+                : ''
+            }
+          </div>
+          <div class=${LitHtml.Directives.classMap(replaySettingsClassMap)}>
+            ${
+              replaySettingsFragments.length
+                ? replaySettingsFragments
+                : LitHtml.html`<div>${i18nString(UIStrings.default)}</div>`
+            }
+          </div>
+        </div>
+        <div class="settings-container">
+          <div class="settings-title">${i18nString(UIStrings.environment)}</div>
+          <div class="settings">
+            ${
+              environmentFragments.length
+                ? environmentFragments
+                : LitHtml.html`<div>${i18nString(UIStrings.default)}</div>`
+            }
+          </div>
+        </div>
+      </div>
+    `;
+    // clang-format on
+  }
+
+  #getCurrentConverter(): Converters.Converter.Converter|undefined {
+    const currentConverter = [
+      ...(this.#builtInConverters || []),
+      ...(this.#extensionConverters || []),
+    ].find(converter => converter.getId() === this.#converterId);
+    if (!currentConverter) {
+      return this.#builtInConverters[0];
+    }
+    return currentConverter;
+  }
+
+  #renderTimelineArea(): LitHtml.LitTemplate {
+    if (this.#extensionDescriptor) {
+      // clang-format off
+      return LitHtml.html`
+        <${ExtensionView.litTagName} .descriptor=${this.#extensionDescriptor}>
+        </${ExtensionView.litTagName}>
+      `;
+      // clang-format on
+    }
+    const currentConverter = this.#getCurrentConverter();
+    const converterFormatName = currentConverter?.getFormatName();
+    // clang-format off
+    return !this.#showCodeView
+      ? this.#renderSections()
+      : LitHtml.html`
+        <${SplitView.litTagName}>
+          <div slot="main">
+            ${this.#renderSections()}
+          </div>
+          <div slot="sidebar">
+            <div class="section-toolbar">
+              <${Menus.SelectMenu.SelectMenu.litTagName}
+                @selectmenuselected=${this.#onCodeFormatChange}
+                .showDivider=${true}
+                .showArrow=${true}
+                .sideButton=${false}
+                .showSelectedItem=${true}
+                .showConnector=${false}
+                .position=${Dialogs.Dialog.DialogVerticalPosition.BOTTOM}
+                .buttonTitle=${converterFormatName}
+              >
+                ${this.#builtInConverters.map(converter => {
+                  return LitHtml.html`<${Menus.Menu.MenuItem.litTagName}
+                    .value=${converter.getId()}
+                    .selected=${this.#converterId === converter.getId()}
+                  >
+                    ${converter.getFormatName()}
+                  </${Menus.Menu.MenuItem.litTagName}>`;
+                })}
+                ${this.#extensionConverters.map(converter => {
+                  return LitHtml.html`<${Menus.Menu.MenuItem.litTagName}
+                    .value=${converter.getId()}
+                    .selected=${this.#converterId === converter.getId()}
+                  >
+                    ${converter.getFormatName()}
+                  </${Menus.Menu.MenuItem.litTagName}>`;
+                })}
+              </${Menus.SelectMenu.SelectMenu.litTagName}>
+              <${Buttons.Button.Button.litTagName}
+                title=${Models.Tooltip.getTooltipForActions(
+                  i18nString(UIStrings.hideCode),
+                  Actions.RecorderActions.ToggleCodeView,
+                )}
+                .data=${
+                  {
+                    variant: Buttons.Button.Variant.ROUND,
+                    size: Buttons.Button.Size.SMALL,
+                    iconUrl: closeIcon,
+                  } as Buttons.Button.ButtonData
+                }
+                @click=${this.showCodeToggle}
+              ></${Buttons.Button.Button.litTagName}>
+            </div>
+            <div class="text-editor">
+              <${TextEditor.TextEditor.TextEditor.litTagName} .state=${
+          this.#editorState
+        }></${TextEditor.TextEditor.TextEditor.litTagName}>
+            </div>
+          </div>
+        </${SplitView.litTagName}>
+      `;
+    // clang-format on
+  }
+
+  #renderScreenshot(
+      section: Models.Section.Section,
+      ): LitHtml.TemplateResult|null {
+    if (!section.screenshot) {
+      return null;
+    }
+
+    // clang-format off
+    return LitHtml.html`
+      <img class="screenshot" src=${section.screenshot} alt=${i18nString(
+      UIStrings.screenshotForSection,
+    )} />
+    `;
+    // clang-format on
+  }
+
+  #renderReplayOrAbortButton(): LitHtml.TemplateResult {
+    if (this.#replayState.isPlaying) {
+      return LitHtml.html`
+        <${Buttons.Button.Button.litTagName} @click=${this.#handleAbortReplay} .iconUrl=${abortIconUrl} .variant=${
+          Buttons.Button.Variant.SECONDARY}>
+          ${i18nString(UIStrings.cancelReplay)}
+        </${Buttons.Button.Button.litTagName}>`;
+    }
+
+    // clang-format off
+    return LitHtml.html`<${ReplayButton.litTagName}
+        .data=${
+          {
+            settings: this.#recorderSettings,
+            replayExtensions: this.#replayExtensions,
+          } as ReplayButtonData
+        }
+        .disabled=${this.#replayState.isPlaying}
+        @startreplay=${this.#handleTogglePlaying}
+        >
+      </${ReplayButton.litTagName}>`;
+    // clang-format on
+  }
+
+  #handleMeasurePerformanceClickEvent(event: Event): void {
+    event.stopPropagation();
+
+    this.dispatchEvent(
+        new PlayRecordingEvent({
+          targetPanel: TargetPanel.PerformancePanel,
+          speed: PlayRecordingSpeed.Normal,
+        }),
+    );
+  }
+
+  showCodeToggle = (): void => {
+    this.#showCodeView = !this.#showCodeView;
+    Host.userMetrics.recordingCodeToggled(
+        this.#showCodeView ? Host.UserMetrics.RecordingCodeToggled.CodeShown :
+                             Host.UserMetrics.RecordingCodeToggled.CodeHidden,
+    );
+    void this.#convertToCode();
+  };
+
+  #convertToCode = async(): Promise<void> => {
+    if (!this.#userFlow) {
+      return;
+    }
+    const converter = this.#getCurrentConverter();
+    if (!converter) {
+      return;
+    }
+    const [code, sourceMap] = await converter.stringify(this.#userFlow);
+    this.#code = code;
+    this.#sourceMap = sourceMap;
+    this.#sourceMap?.shift();
+    const mediaType = converter.getMediaType();
+    const languageSupport = mediaType ? await CodeHighlighter.CodeHighlighter.languageFromMIME(mediaType) : null;
+    this.#editorState = CodeMirror.EditorState.create({
+      doc: this.#code,
+      extensions: [
+        TextEditor.Config.baseConfiguration(this.#code),
+        CodeMirror.EditorState.readOnly.of(true),
+        CodeMirror.EditorView.lineWrapping,
+        languageSupport ? languageSupport : [],
+      ],
+    });
+    this.#render();
+    // Used by tests.
+    this.dispatchEvent(new Event('code-generated'));
+  };
+
+  #highlightCodeForStep = (step: Models.Schema.Step, scroll = false): void => {
+    if (!this.#sourceMap) {
+      return;
+    }
+
+    const stepIndex = this.#steps.indexOf(step);
+    if (stepIndex === -1) {
+      return;
+    }
+
+    const editor = this.#shadow.querySelector('devtools-text-editor') as | TextEditor.TextEditor.TextEditor | undefined;
+    if (!editor) {
+      return;
+    }
+
+    const cm = editor.editor;
+    if (!cm) {
+      return;
+    }
+
+    const line = this.#sourceMap[stepIndex * 2];
+    const length = this.#sourceMap[stepIndex * 2 + 1];
+
+    let selection = editor.createSelection(
+        {lineNumber: line + length, columnNumber: 0},
+        {lineNumber: line, columnNumber: 0},
+    );
+    const lastLine = editor.state.doc.lineAt(selection.main.anchor);
+    selection = editor.createSelection(
+        {lineNumber: line + length - 1, columnNumber: lastLine.length + 1},
+        {lineNumber: line, columnNumber: 0},
+    );
+
+    cm.dispatch({
+      selection,
+      effects: scroll ?
+          [
+            CodeMirror.EditorView.scrollIntoView(selection.main, {
+              y: 'nearest',
+            }),
+          ] :
+          undefined,
+    });
+  };
+
+  # Menus.SelectMenu.SelectMenuItemSelectedEvent): void => {
+    this.#converterId = event.itemValue as string;
+    if (this.#recorderSettings) {
+      this.#recorderSettings.preferredCopyFormat = event.itemValue as string;
+    }
+
+    void this.#convertToCode();
+  };
+
+  #renderSections(): LitHtml.LitTemplate {
+    // clang-format off
+    return LitHtml.html`
+      <div class="sections">
+      ${
+        !this.#showCodeView
+          ? LitHtml.html`<div class="section-toolbar">
+        <${Buttons.Button.Button.litTagName}
+          @click=${this.showCodeToggle}
+          class="show-code"
+          .data=${
+            {
+              variant: Buttons.Button.Variant.SECONDARY,
+              title: Models.Tooltip.getTooltipForActions(
+                i18nString(UIStrings.showCode),
+                Actions.RecorderActions.ToggleCodeView,
+              ),
+            } as Buttons.Button.ButtonData
+          }
+        >
+          ${i18nString(UIStrings.showCode)}
+        </${Buttons.Button.Button.litTagName}>
+      </div>`
+          : ''
+      }
+      ${this.#sections.map(
+        (section, i) => LitHtml.html`
+            <div class="section">
+              <div class="screenshot-wrapper">
+                ${this.#renderScreenshot(section)}
+              </div>
+              <div class="content">
+                <div class="steps">
+                  <${StepView.litTagName}
+                    @click=${this.#onStepClick}
+                    @mouseover=${this.#onStepHover}
+                    .data=${
+                      {
+                        section,
+                        state: this.#getSectionState(section),
+                        isStartOfGroup: true,
+                        isEndOfGroup: section.steps.length === 0,
+                        isFirstSection: i === 0,
+                        isLastSection:
+                          i === this.#sections.length - 1 &&
+                          section.steps.length === 0,
+                        isSelected:
+                          this.#selectedStep === (section.causingStep || null),
+                        sectionIndex: i,
+                        isRecording: this.#isRecording,
+                        isPlaying: this.#replayState.isPlaying,
+                        error:
+                          this.#getSectionState(section) === State.Error
+                            ? this.#currentError
+                            : undefined,
+                        hasBreakpoint: false,
+                        removable: this.#steps.length > 1 && section.causingStep,
+                      } as StepViewData
+                    }
+                  >
+                  </${StepView.litTagName}>
+                  ${section.steps.map(step =>
+                    this.#renderStep(
+                      section,
+                      step,
+                      i === this.#sections.length - 1,
+                    ),
+                  )}
+                  ${!this.#recordingTogglingInProgress && this.#isRecording && i === this.#sections.length - 1 ? LitHtml.html`<devtools-button
+                    class="step add-assertion-button"
+                    .data=${
+                      {
+                        variant: Buttons.Button.Variant.SECONDARY,
+                        title: i18nString(UIStrings.addAssertion),
+                      } as Buttons.Button.ButtonData
+                    }
+                    @click=${this.#dispatchAddAssertionEvent}
+                  >${i18nString(UIStrings.addAssertion)}</devtools-button>` : undefined}
+                  ${
+                    this.#isRecording && i === this.#sections.length - 1
+                      ? LitHtml.html`<div class="step recording">${i18nString(
+                          UIStrings.recording,
+                        )}</div>`
+                      : null
+                  }
+                </div>
+              </div>
+            </div>
+      `,
+      )}
+      </div>
+    `;
+    // clang-format on
+  }
+
+  #renderHeader(): LitHtml.LitTemplate|string {
+    if (!this.#userFlow) {
+      return '';
+    }
+    const {title} = this.#userFlow;
+    const titleInputWidth = this.#titleInputWidth + (this.#titleInputFocused ? 16 : 0);
+
+    // clang-format off
+    return LitHtml.html`
+      <div class="header">
+        <div class="header-title-wrapper">
+          <div class="header-title">
+            <span id="title-input-label">${title}</span>
+            <input @focus=${this.#onTitleInputFocus}
+                  @blur=${this.#onTitleInputBlur}
+                  @input=${this.#onTitleInput}
+                  @keydown=${this.#onTitleInputKeyDown}
+                  id="title-input"
+                  type="text"
+                  required
+                  class=${LitHtml.Directives.classMap({
+                    'has-error': !this.#isTitleInputValid(),
+                  })}
+                  style=${LitHtml.Directives.styleMap({
+                    width: this.#isTitleInputValid()
+                      ? `${titleInputWidth}px`
+                      : undefined,
+                  })}
+                  .value=${title}
+                  .disabled=${this.#replayState.isPlaying || this.#isRecording}>
+            <div class="title-button-bar">
+              <${Buttons.Button.Button.litTagName}
+                @click=${this.#onEditTitleButtonClick}
+                .data=${
+                  {
+                    disabled: this.#replayState.isPlaying || this.#isRecording,
+                    variant: Buttons.Button.Variant.TOOLBAR,
+                    iconUrl: editIconUrl,
+                    title: i18nString(UIStrings.editTitle),
+                  } as Buttons.Button.ButtonData
+                }
+              ></${Buttons.Button.Button.litTagName}>
+            </div>
+          </div>
+          ${
+            !this.#isTitleInputValid()
+              ? LitHtml.html`<div class="title-input-error-text">
+            ${
+              this.#titleInput?.validity.valueMissing
+                ? i18nString(UIStrings.requiredTitleError)
+                : ''
+            }
+          </div>`
+              : ''
+          }
+        </div>
+        ${
+          !this.#isRecording && this.#replayAllowed
+            ? LitHtml.html`<div class="actions">
+                <${Buttons.Button.Button.litTagName}
+                  @click=${this.#handleMeasurePerformanceClickEvent}
+                  .data=${
+                    {
+                      disabled: this.#replayState.isPlaying,
+                      variant: Buttons.Button.Variant.SECONDARY,
+                      iconUrl: throttlingIconUrl,
+                      title: i18nString(UIStrings.performancePanel),
+                    } as Buttons.Button.ButtonData
+                  }
+                >
+                  ${i18nString(UIStrings.performancePanel)}
+                </${Buttons.Button.Button.litTagName}>
+                ${this.#renderReplayOrAbortButton()}
+              </div>`
+            : ''
+        }
+      </div>`;
+            // clang-format on
+  }
+
+  #renderFooter(): LitHtml.LitTemplate|string {
+    if (!this.#isRecording) {
+      return '';
+    }
+    const translation = this.#recordingTogglingInProgress ? i18nString(UIStrings.recordingIsBeingStopped) :
+                                                            i18nString(UIStrings.endRecording);
+    // clang-format off
+    return LitHtml.html`
+      <div class="footer">
+        <div class="controls">
+          <devtools-control-button
+            @click=${this.#dispatchRecordingFinished}
+            .disabled=${this.#recordingTogglingInProgress}
+            .shape=${'square'}
+            .label=${translation}
+            title=${Models.Tooltip.getTooltipForActions(
+              translation,
+              Actions.RecorderActions.StartRecording,
+            )}
+          >
+          </devtools-control-button>
+        </div>
+      </div>
+    `;
+    // clang-format on
+  }
+
+  #render(): void {
+    const classNames = {
+      wrapper: true,
+      'is-recording': this.#isRecording,
+      'is-playing': this.#replayState.isPlaying,
+      'was-successful': this.#lastReplayResult === Models.RecordingPlayer.ReplayResult.Success,
+      'was-failure': this.#lastReplayResult === Models.RecordingPlayer.ReplayResult.Failure,
+    };
+
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+      <div @click=${this.#onWrapperClick} class=${LitHtml.Directives.classMap(
+        classNames,
+      )}>
+        <div class="main">
+          ${this.#renderHeader()}
+          ${
+            this.#extensionDescriptor
+              ? LitHtml.html`
+            <${ExtensionView.litTagName} .descriptor=${
+                  this.#extensionDescriptor
+                }>
+            </${ExtensionView.litTagName}>
+          `
+              : LitHtml.html`
+            ${this.#renderSettings()}
+            ${this.#renderTimelineArea()}
+          `
+          }
+          ${this.#renderFooter()}
+        </div>
+      </div>
+    `,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, () => {
+      if (this.#titleInputChanged) {
+        this.#titleInputChanged = false;
+        this.#measureTitleInput();
+        this.#render();
+      }
+    });
+  }
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-recording-view',
+    RecordingView,
+);
diff --git a/front_end/panels/recorder/components/ReplayButton.ts b/front_end/panels/recorder/components/ReplayButton.ts
new file mode 100644
index 0000000..076a123
--- /dev/null
+++ b/front_end/panels/recorder/components/ReplayButton.ts
@@ -0,0 +1,251 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Host from '../../../core/host/host.js';
+import * as i18n from '../../../core/i18n/i18n.js';
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+
+import type * as Models from '../models/models.js';
+import {PlayRecordingSpeed} from '../models/RecordingPlayer.js';
+import * as Actions from '../recorder-actions.js';  // eslint-disable-line rulesdir/es_modules_import
+import type * as Extensions from '../extensions/extensions.js';
+
+import {
+  SelectButton,
+  Variant as SelectButtonVariant,
+  type SelectButtonItem,
+  type SelectButtonClickEvent,
+} from './SelectButton.js';
+
+const playIconUrl = new URL(
+                        '../images/play_icon.svg',
+                        import.meta.url,
+                        )
+                        .toString();
+
+const UIStrings = {
+  /**
+   * @description Button label for the normal speed replay option
+   */
+  ReplayNormalButtonLabel: 'Replay',
+  /**
+   * @description Item label for the normal speed replay option
+   */
+  ReplayNormalItemLabel: 'Normal (Default)',
+  /**
+   * @description Button label for the slow speed replay option
+   */
+  ReplaySlowButtonLabel: 'Slow replay',
+  /**
+   * @description Item label for the slow speed replay option
+   */
+  ReplaySlowItemLabel: 'Slow',
+  /**
+   * @description Button label for the very slow speed replay option
+   */
+  ReplayVerySlowButtonLabel: 'Very slow replay',
+  /**
+   * @description Item label for the very slow speed replay option
+   */
+  ReplayVerySlowItemLabel: 'Very slow',
+  /**
+   * @description Button label for the extremely slow speed replay option
+   */
+  ReplayExtremelySlowButtonLabel: 'Extremely slow replay',
+  /**
+   * @description Item label for the slow speed replay option
+   */
+  ReplayExtremelySlowItemLabel: 'Extremely slow',
+  /**
+   * @description Label for a group of items in the replay menu that indicate various replay speeds (e.g., Normal, Fast, Slow).
+   */
+  speedGroup: 'Speed',
+  /**
+   * @description Label for a group of items in the replay menu that indicate various extensions that can be used for replay.
+   */
+  extensionGroup: 'Extensions',
+};
+
+const items: SelectButtonItem[] = [
+  {
+    value: PlayRecordingSpeed.Normal,
+    buttonIconUrl: playIconUrl,
+    buttonLabel: (): string => i18nString(UIStrings.ReplayNormalButtonLabel),
+    label: (): string => i18nString(UIStrings.ReplayNormalItemLabel),
+  },
+  {
+    value: PlayRecordingSpeed.Slow,
+    buttonIconUrl: playIconUrl,
+    buttonLabel: (): string => i18nString(UIStrings.ReplaySlowButtonLabel),
+    label: (): string => i18nString(UIStrings.ReplaySlowItemLabel),
+  },
+  {
+    value: PlayRecordingSpeed.VerySlow,
+    buttonIconUrl: playIconUrl,
+    buttonLabel: (): string => i18nString(UIStrings.ReplayVerySlowButtonLabel),
+    label: (): string => i18nString(UIStrings.ReplayVerySlowItemLabel),
+  },
+  {
+    value: PlayRecordingSpeed.ExtremelySlow,
+    buttonIconUrl: playIconUrl,
+    buttonLabel: (): string => i18nString(UIStrings.ReplayExtremelySlowButtonLabel),
+    label: (): string => i18nString(UIStrings.ReplayExtremelySlowItemLabel),
+  },
+];
+
+const replaySpeedToMetricSpeedMap = {
+  [PlayRecordingSpeed.Normal]: Host.UserMetrics.RecordingReplaySpeed.Normal,
+  [PlayRecordingSpeed.Slow]: Host.UserMetrics.RecordingReplaySpeed.Slow,
+  [PlayRecordingSpeed.VerySlow]: Host.UserMetrics.RecordingReplaySpeed.VerySlow,
+  [PlayRecordingSpeed.ExtremelySlow]: Host.UserMetrics.RecordingReplaySpeed.ExtremelySlow,
+} as const;
+
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/components/ReplayButton.ts',
+    UIStrings,
+);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+export class StartReplayEvent extends Event {
+  static readonly eventName = 'startreplay';
+
+  constructor(
+      public speed: PlayRecordingSpeed,
+      public extension?: Extensions.ExtensionManager.Extension,
+  ) {
+    super(StartReplayEvent.eventName, {bubbles: true, composed: true});
+  }
+}
+
+export interface ReplayButtonProps {
+  disabled: boolean;
+}
+
+export interface ReplayButtonData {
+  settings: Models.RecorderSettings.RecorderSettings;
+  replayExtensions: Extensions.ExtensionManager.Extension[];
+}
+
+const REPLAY_EXTENSION_PREFIX = 'extension';
+
+export class ReplayButton extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-replay-button`;
+  readonly #shadow = this.attachShadow({mode: 'open'});
+  readonly #boundRender = this.#render.bind(this);
+  readonly #props: ReplayButtonProps = {disabled: false};
+  #settings?: Models.RecorderSettings.RecorderSettings;
+  #replayExtensions: Extensions.ExtensionManager.Extension[] = [];
+
+  set data(data: ReplayButtonData) {
+    this.#settings = data.settings;
+    this.#replayExtensions = data.replayExtensions;
+  }
+
+  get disabled(): boolean {
+    return this.#props.disabled;
+  }
+
+  set disabled(disabled: boolean) {
+    this.#props.disabled = disabled;
+    void ComponentHelpers.ScheduledRender.scheduleRender(
+        this,
+        this.#boundRender,
+    );
+  }
+
+  connectedCallback(): void {
+    void ComponentHelpers.ScheduledRender.scheduleRender(
+        this,
+        this.#boundRender,
+    );
+  }
+
+  #handleSelectButtonClick(event: SelectButtonClickEvent): void {
+    event.stopPropagation();
+
+    if (event.value.startsWith(REPLAY_EXTENSION_PREFIX)) {
+      if (this.#settings) {
+        this.#settings.replayExtension = event.value;
+      }
+      const extensionIdx = Number(
+          event.value.substring(REPLAY_EXTENSION_PREFIX.length),
+      );
+      this.dispatchEvent(
+          new StartReplayEvent(
+              PlayRecordingSpeed.Normal,
+              this.#replayExtensions[extensionIdx],
+              ),
+      );
+      void ComponentHelpers.ScheduledRender.scheduleRender(
+          this,
+          this.#boundRender,
+      );
+      return;
+    }
+
+    const speed = event.value as PlayRecordingSpeed;
+    if (this.#settings) {
+      this.#settings.speed = speed;
+      this.#settings.replayExtension = '';
+    }
+
+    Host.userMetrics.recordingReplaySpeed(replaySpeedToMetricSpeedMap[speed]);
+    this.dispatchEvent(new StartReplayEvent(event.value as PlayRecordingSpeed));
+    void ComponentHelpers.ScheduledRender.scheduleRender(
+        this,
+        this.#boundRender,
+    );
+  }
+
+  #render(): void {
+    const groups = [{name: i18nString(UIStrings.speedGroup), items}];
+
+    if (this.#replayExtensions.length) {
+      groups.push({
+        name: i18nString(UIStrings.extensionGroup),
+        items: this.#replayExtensions.map((extension, idx) => {
+          return {
+            value: REPLAY_EXTENSION_PREFIX + idx,
+            buttonIconUrl: playIconUrl,
+            buttonLabel: (): string => extension.getName(),
+            label: (): string => extension.getName(),
+          };
+        }),
+      });
+    }
+
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+    <${SelectButton.litTagName}
+      @selectbuttonclick=${this.#handleSelectButtonClick}
+      .variant=${SelectButtonVariant.PRIMARY}
+      .showItemDivider=${false}
+      .disabled=${this.#props.disabled}
+      .action=${Actions.RecorderActions.ReplayRecording}
+      .value=${this.#settings?.replayExtension || this.#settings?.speed}
+      .groups=${groups}>
+    </${SelectButton.litTagName}>`,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+  }
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-replay-button',
+    ReplayButton,
+);
+
+declare global {
+  interface HTMLElementEventMap {
+    startreplay: StartReplayEvent;
+  }
+
+  interface HTMLElementTagNameMap {
+    'devtools-replay-button': ReplayButton;
+  }
+}
diff --git a/front_end/panels/recorder/components/SelectButton.ts b/front_end/panels/recorder/components/SelectButton.ts
new file mode 100644
index 0000000..e6f25e7
--- /dev/null
+++ b/front_end/panels/recorder/components/SelectButton.ts
@@ -0,0 +1,269 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Buttons from '../../../ui/components/buttons/buttons.js';
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+import * as Dialogs from '../../../ui/components/dialogs/dialogs.js';
+import * as Menus from '../../../ui/components/menus/menus.js';
+import * as Models from '../models/models.js';
+
+import type * as Actions from '../recorder-actions.js'; // eslint-disable-line rulesdir/es_modules_import
+
+import selectButtonStyles from './selectButton.css.js';
+
+export const enum Variant {
+  PRIMARY = 'primary',
+  SECONDARY = 'secondary',
+}
+
+type SelectMenuGroup = {
+  name: string,
+  items: SelectButtonItem[],
+};
+
+interface SelectButtonProps {
+  /**
+   * Whether the button is disabled or not
+   * Defaults to false
+   */
+  disabled: boolean;
+  /**
+   * Current value of the button
+   * The same value must correspond to an item in the `items` array
+   */
+  value: string;
+  /**
+   * Items for the select menu of the button
+   * Selected item is shown in the button itself
+   */
+  items: SelectButtonItem[];
+  /**
+   * Groups for the select menu of the button.
+   */
+  groups: Array<SelectMenuGroup>;
+  /**
+   * Similar to the button variant
+   */
+  variant: Variant;
+  /**
+   * Action that the button is linked to
+   */
+  action?: Actions.RecorderActions;
+}
+
+export interface SelectButtonItem {
+  /**
+   * Specifies the clicked item
+   */
+  value: string;
+  /**
+   * `icon` to be shown on the button
+   */
+  buttonIconUrl?: string;
+  /**
+   * Text to be shown in the select menu
+   */
+  label: () => string;
+  /**
+   * Text to be shown in the button when the item is selected for the button
+   */
+  buttonLabel?: () => string;
+}
+
+export class SelectButtonClickEvent extends Event {
+  static readonly eventName = 'selectbuttonclick';
+
+  constructor(public value: string) {
+    super(SelectButtonClickEvent.eventName, {bubbles: true, composed: true});
+  }
+}
+
+export class SelectButton extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-select-button`;
+  readonly #shadow = this.attachShadow({mode: 'open'});
+  readonly #props: SelectButtonProps = {
+    disabled: false,
+    value: '',
+    items: [],
+    groups: [],
+    variant: Variant.PRIMARY,
+  };
+
+  connectedCallback(): void {
+    this.#shadow.adoptedStyleSheets = [selectButtonStyles];
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  get disabled(): boolean {
+    return this.#props.disabled;
+  }
+
+  set disabled(disabled: boolean) {
+    this.#props.disabled = disabled;
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  get items(): SelectButtonItem[] {
+    return this.#props.items;
+  }
+
+  set items(items: SelectButtonItem[]) {
+    this.#props.items = items;
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  set groups(groups: Array<SelectMenuGroup>) {
+    this.#props.groups = groups;
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  get value(): string {
+    return this.#props.value;
+  }
+
+  set value(value: string) {
+    this.#props.value = value;
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  get variant(): Variant {
+    return this.#props.variant;
+  }
+
+  set variant(variant: Variant) {
+    this.#props.variant = variant;
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  set action(value: Actions.RecorderActions) {
+    this.#props.action = value;
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  #handleClick(ev: Event): void {
+    ev.stopPropagation();
+    this.dispatchEvent(new SelectButtonClickEvent(this.#props.value));
+  }
+
+  #handleSelectMenuSelect(
+      evt: Menus.SelectMenu.SelectMenuItemSelectedEvent,
+      ): void {
+    this.dispatchEvent(new SelectButtonClickEvent(evt.itemValue as string));
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  #renderSelectItem(
+      item: SelectButtonItem,
+      selectedItem: SelectButtonItem,
+      ): LitHtml.TemplateResult {
+    // clang-format off
+    return LitHtml.html`
+      <${Menus.Menu.MenuItem.litTagName} .value=${item.value} .selected=${
+      item.value === selectedItem.value
+    }>
+        ${item.label()}
+      </${Menus.Menu.MenuItem.litTagName}>
+    `;
+    // clang-format on
+  }
+
+  #renderSelectGroup(
+      group: SelectMenuGroup,
+      selectedItem: SelectButtonItem,
+      ): LitHtml.TemplateResult {
+    // clang-format off
+    return LitHtml.html`
+      <${Menus.Menu.MenuGroup.litTagName} .name=${group.name}>
+        ${group.items.map(item => this.#renderSelectItem(item, selectedItem))}
+      </${Menus.Menu.MenuGroup.litTagName}>
+    `;
+    // clang-format on
+  }
+
+  #getTitle(label: string): string {
+    return this.#props.action ? Models.Tooltip.getTooltipForActions(label, this.#props.action) : '';
+  }
+
+  #render = (): void => {
+    const hasGroups = Boolean(this.#props.groups.length);
+    const items = hasGroups ? this.#props.groups.flatMap(group => group.items) : this.#props.items;
+    const selectedItem = items.find(item => item.value === this.#props.value) || items[0];
+    if (!selectedItem) {
+      return;
+    }
+
+    const classes = {
+      primary: this.#props.variant === Variant.PRIMARY,
+      secondary: this.#props.variant === Variant.SECONDARY,
+    };
+
+    const buttonVariant =
+        this.#props.variant === Variant.SECONDARY ? Buttons.Button.Variant.SECONDARY : Buttons.Button.Variant.PRIMARY;
+    const label = selectedItem.buttonLabel ? selectedItem.buttonLabel() : selectedItem.label();
+
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+      <div class="select-button" title=${
+        this.#getTitle(label) || LitHtml.nothing
+      }>
+        ${
+          selectedItem
+            ? LitHtml.html`
+        <${Buttons.Button.Button.litTagName}
+            .disabled=${this.#props.disabled}
+            .variant=${buttonVariant}
+            .iconUrl=${selectedItem.buttonIconUrl}
+            @click=${this.#handleClick}>
+            ${label}
+        </${Buttons.Button.Button.litTagName}>`
+            : ''
+        }
+        <${Menus.SelectMenu.SelectMenu.litTagName}
+          class=${LitHtml.Directives.classMap(classes)}
+          @selectmenuselected=${this.#handleSelectMenuSelect}
+          ?disabled=${this.#props.disabled}
+          .showArrow=${true}
+          .sideButton=${false}
+          .showSelectedItem=${true}
+          .disabled=${this.#props.disabled}
+          .buttonTitle=${LitHtml.html``}
+          .position=${Dialogs.Dialog.DialogVerticalPosition.BOTTOM}
+          .horizontalAlignment=${
+            Dialogs.Dialog.DialogHorizontalAlignment.RIGHT
+          }
+        >
+          ${
+            hasGroups
+              ? this.#props.groups.map(group =>
+                  this.#renderSelectGroup(group, selectedItem),
+                )
+              : this.#props.items.map(item =>
+                  this.#renderSelectItem(item, selectedItem),
+                )
+          }
+        </${Menus.SelectMenu.SelectMenu.litTagName}>
+      </div>`,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+  };
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-select-button',
+    SelectButton,
+);
+
+declare global {
+  interface HTMLElementEventMap {
+    selectbuttonclick: SelectButtonClickEvent;
+  }
+
+  interface HTMLElementTagNameMap {
+    'devtools-select-button': SelectButton;
+  }
+}
diff --git a/front_end/panels/recorder/components/SplitView.ts b/front_end/panels/recorder/components/SplitView.ts
new file mode 100644
index 0000000..489ca9a
--- /dev/null
+++ b/front_end/panels/recorder/components/SplitView.ts
@@ -0,0 +1,192 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../../../ui/legacy/legacy.js';
+
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+
+// clean-css does not compile this file correctly. So as a workaround adding styles inline.
+const styles = `
+  :host {
+    --current-main-area-size: 50%;
+    --resizer-size: 3px;
+    --min-main-area-size: 200px;
+    --min-sidebar-size: 150px;
+    --main-area-size: calc(max(var(--current-main-area-size), var(--min-main-area-size)));
+
+    height: 100%;
+    width: 100%;
+    display: block;
+    overflow: auto;
+  }
+
+  .wrapper {
+    display: flex;
+    flex-direction: row;
+    height: 100%;
+    width: 100%;
+    container: sidebar / size; /* stylelint-disable-line property-no-unknown */
+  }
+
+  .container {
+    --resizer-position: calc(min(var(--main-area-size), calc(100% - var(--min-sidebar-size))));
+    --min-container-size: calc(var(--min-sidebar-size) + var(--min-main-area-size) + var(--resizer-size));
+
+    display: flex;
+    flex-direction: row;
+    height: 100%;
+    width: 100%;
+    position: relative;
+    gap: var(--resizer-size);
+
+    min-width: var(--min-container-size);
+  }
+
+  #resizer {
+    background-color: var(--color-background-elevation-1);
+    position: absolute;
+    user-select: none;
+
+    /* horizontal */
+    width: var(--resizer-size);
+    cursor: col-resize;
+    left: var(--resizer-position);
+    bottom: 0;
+    top: 0;
+  }
+
+  slot {
+    overflow: auto;
+    display: block;
+  }
+
+  slot[name="main"] {
+
+    /* horizontal */
+    width: var(--resizer-position);
+    min-width: var(--min-main-area-size);
+  }
+
+  slot[name="sidebar"] {
+    flex: 1 0 0;
+
+    min-width: var(--min-sidebar-size);
+  }
+
+  @container sidebar (max-width: 600px) and (min-height: 600px) { /* stylelint-disable-line at-rule-no-unknown */
+    .container {
+      flex-direction: column;
+      min-height: var(--min-container-size);
+      min-width: auto;
+    }
+
+    #resizer {
+      width: auto;
+      height: var(--resizer-size);
+      cursor: row-resize;
+      top: var(--resizer-position);
+      left: 0;
+      right: 0;
+    }
+
+    slot[name="main"] {
+      width: auto;
+      min-width: auto;
+      height: var(--resizer-position);
+      min-height: var(--min-main-area-size);
+    }
+
+    slot[name="sidebar"] {
+      min-width: auto;
+      min-height: var(--min-sidebar-size);
+    }
+  }
+`;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-split-view': SplitView;
+  }
+}
+
+const splitViewStyles = new CSSStyleSheet();
+splitViewStyles.replaceSync(styles);
+
+export class SplitView extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-split-view`;
+  readonly #shadow = this.attachShadow({mode: 'open'});
+  #mousePos = [0, 0];
+  #mainAxisIdx = 0;
+  #mainDimensions = [0, 0];
+  #observer?: ResizeObserver;
+
+  connectedCallback(): void {
+    this.style.setProperty('--current-main-area-size', '60%');
+    this.#shadow.adoptedStyleSheets = [splitViewStyles];
+    this.#observer = new ResizeObserver(
+        entries => this.#onResize(entries[0].contentRect),
+    );
+    this.#observer.observe(this);
+    this.#render();
+  }
+
+  # DOMRectReadOnly): void => {
+    if (rect.width <= 600 && rect.height >= 600) {
+      this.#mainAxisIdx = 1;
+    } else {
+      this.#mainAxisIdx = 0;
+    }
+    this.style.setProperty('--current-main-area-size', '60%');
+  };
+
+  # MouseEvent): void => {
+    const main = this.#shadow.querySelector('slot[name=main]');
+    if (!main) {
+      throw new Error('Main slot not found');
+    }
+    const rect = main.getBoundingClientRect();
+    this.#mainDimensions = [rect.width, rect.height];
+    this.#mousePos = [event.clientX, event.clientY];
+    window.addEventListener('mousemove', this.#onMouseMove, true);
+    window.addEventListener('mouseup', this.#onMouseUp, true);
+  };
+
+  # void => {
+    window.removeEventListener('mousemove', this.#onMouseMove, true);
+    window.removeEventListener('mouseup', this.#onMouseUp, true);
+  };
+
+  # MouseEvent): void => {
+    const mousePos = [event.clientX, event.clientY];
+    const delta = mousePos[this.#mainAxisIdx] - this.#mousePos[this.#mainAxisIdx];
+    const rect = this.getBoundingClientRect();
+    const containerDimensions = [rect.width, rect.height];
+    const length = ((this.#mainDimensions[this.#mainAxisIdx] + delta) * 100) / containerDimensions[this.#mainAxisIdx];
+    this.style.setProperty('--current-main-area-size', length + '%');
+  };
+
+  #render = (): void => {
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+        <div class="wrapper">
+          <div class="container">
+            <slot name="main"></slot>
+            <div id="resizer" @mousedown=${this.#onMouseDown}></div>
+            <slot name="sidebar"></slot>
+          </div>
+        </div>
+      `,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+  };
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-split-view',
+    SplitView,
+);
diff --git a/front_end/panels/recorder/components/StartView.ts b/front_end/panels/recorder/components/StartView.ts
new file mode 100644
index 0000000..dd68052
--- /dev/null
+++ b/front_end/panels/recorder/components/StartView.ts
@@ -0,0 +1,130 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../../../ui/legacy/legacy.js';
+
+import * as i18n from '../../../core/i18n/i18n.js';
+import type * as Platform from '../../../core/platform/platform.js';
+import * as Buttons from '../../../ui/components/buttons/buttons.js';
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as PanelFeedback from '../../../ui/components/panel_feedback/panel_feedback.js';
+import * as PanelIntroductionSteps from '../../../ui/components/panel_introduction_steps/panel_introduction_steps.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+
+import startViewStyles from './startView.css.js';
+
+const UIStrings = {
+  /**
+   * @description The header of the start page in the Recorder panel.
+   */
+  header: 'Measure performance across an entire user journey',
+  /**
+   * @description The Recorder start page contains a list of steps that the user can do. This is the text of the first step.
+   */
+  step1: 'Record a common user journey on your website or app',
+  /**
+   * @description The Recorder start page contains a list of steps that the user can do. This is the text of the second step.
+   */
+  step2: 'Replay the recording to check if the flow is working',
+  /**
+   * @description The Recorder start page contains a list of steps that the user can do. This is the text of the third step.
+   */
+  step3: 'Generate a detailed performance trace or export a Puppeteer script for testing',
+  /**
+   * @description The title of the button that leads to the page for creating a new recording.
+   */
+  createRecording: 'Create a new recording',
+  /**
+   * @description The link title in the Preview feature box leading to an article documenting the recorder feature.
+   */
+  quickStart: 'Quick start: learn the new Recorder panel in DevTools',
+};
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/components/StartView.ts',
+    UIStrings,
+);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+export const FEEDBACK_URL = 'https://goo.gle/recorder-feedback' as Platform.DevToolsPath.UrlString;
+const DOC_URL = 'https://developer.chrome.com/docs/devtools/recorder' as Platform.DevToolsPath.UrlString;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-start-view': StartView;
+  }
+}
+
+export class CreateRecordingEvent extends Event {
+  static readonly eventName = 'createrecording';
+  constructor() {
+    super(CreateRecordingEvent.eventName);
+  }
+}
+
+export class StartView extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-start-view`;
+  readonly #shadow = this.attachShadow({mode: 'open'});
+
+  connectedCallback(): void {
+    this.#shadow.adoptedStyleSheets = [startViewStyles];
+    void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+  }
+
+  #onClick(): void {
+    this.dispatchEvent(new CreateRecordingEvent());
+  }
+
+  #render = (): void => {
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+        <div class="wrapper">
+          <${
+            PanelIntroductionSteps.PanelIntroductionSteps.PanelIntroductionSteps
+              .litTagName
+          }>
+            <span slot="title">${i18nString(UIStrings.header)}</span>
+            <span slot="step-1">${i18nString(UIStrings.step1)}</span>
+            <span slot="step-2">${i18nString(UIStrings.step2)}</span>
+            <span slot="step-3">${i18nString(UIStrings.step3)}</span>
+          </${
+            PanelIntroductionSteps.PanelIntroductionSteps.PanelIntroductionSteps
+              .litTagName
+          }>
+          <div class="fit-content">
+            <${Buttons.Button.Button.litTagName} .variant=${
+        Buttons.Button.Variant.PRIMARY
+      } @click=${this.#onClick}>
+              ${i18nString(UIStrings.createRecording)}
+            </${Buttons.Button.Button.litTagName}>
+          </div>
+          <${PanelFeedback.PanelFeedback.PanelFeedback.litTagName} .data=${
+        {
+          feedbackUrl: FEEDBACK_URL,
+          quickStartUrl: DOC_URL,
+          quickStartLinkText: i18nString(UIStrings.quickStart),
+        } as PanelFeedback.PanelFeedback.PanelFeedbackData
+      }>
+          </${PanelFeedback.PanelFeedback.PanelFeedback.litTagName}>
+          <div class="align-right">
+            <${PanelFeedback.FeedbackButton.FeedbackButton.litTagName} .data=${
+        {
+          feedbackUrl: FEEDBACK_URL,
+        } as PanelFeedback.FeedbackButton.FeedbackButtonData
+      }>
+            </${PanelFeedback.FeedbackButton.FeedbackButton.litTagName}>
+          </div>
+        </div>
+      `,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+  };
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-start-view',
+    StartView,
+);
diff --git a/front_end/panels/recorder/components/StepEditor.ts b/front_end/panels/recorder/components/StepEditor.ts
new file mode 100644
index 0000000..fa65303
--- /dev/null
+++ b/front_end/panels/recorder/components/StepEditor.ts
@@ -0,0 +1,1212 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Host from '../../../core/host/host.js';
+import * as i18n from '../../../core/i18n/i18n.js';
+import * as Platform from '../../../core/platform/platform.js';
+import type * as Puppeteer from '../../../third_party/puppeteer/puppeteer.js';
+import * as Buttons from '../../../ui/components/buttons/buttons.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+import * as Controllers from '../controllers/controllers.js';
+import * as Models from '../models/models.js';
+import * as Util from '../util/util.js';
+
+import {RecorderInput} from './RecorderInput.js';
+import stepEditorStyles from './stepEditor.css.js';
+
+import {
+  ArrayAssignments,
+  assert,
+  deepFreeze,
+  immutableDeepAssign,
+  InsertAssignment,
+  type Assignments,
+  type DeepImmutable,
+  type DeepPartial,
+  type Keys,
+  type OptionalKeys,
+  type RequiredKeys,
+} from './util.js';
+
+const {html, Decorators, Directives, LitElement} = LitHtml;
+const {customElement, property, state} = Decorators;
+const {live} = Directives;
+
+type StepFor<Type> = Extract<Models.Schema.Step, {type: Type}>;
+type Attribute = Keys<Models.Schema.Step>;
+
+type DataType<A extends Attribute> = ReturnType<typeof typeConverters[typeof dataTypeByAttribute[A]]>;
+
+const typeConverters = Object.freeze({
+  string: (value: string): string => value.trim(),
+  number: (value: string): number => {
+    const number = parseFloat(value);
+    if (Number.isNaN(number)) {
+      return 0;
+    }
+    return number;
+  },
+  boolean: (value: string): boolean => {
+    if (value.toLowerCase() === 'true') {
+      return true;
+    }
+    return false;
+  },
+});
+
+const dataTypeByAttribute = Object.freeze({
+  selectors: 'string',
+  offsetX: 'number',
+  offsetY: 'number',
+  target: 'string',
+  frame: 'number',
+  assertedEvents: 'string',
+  value: 'string',
+  key: 'string',
+  operator: 'string',
+  count: 'number',
+  expression: 'string',
+  x: 'number',
+  y: 'number',
+  url: 'string',
+  type: 'string',
+  timeout: 'number',
+  duration: 'number',
+  button: 'string',
+  deviceType: 'string',
+  width: 'number',
+  height: 'number',
+  deviceScaleFactor: 'number',
+  isMobile: 'boolean',
+  hasTouch: 'boolean',
+  isLandscape: 'boolean',
+  download: 'number',
+  upload: 'number',
+  latency: 'number',
+  name: 'string',
+  parameters: 'string',
+  visible: 'boolean',
+  properties: 'string',
+  attributes: 'string',
+} as const);
+
+const defaultValuesByAttribute = deepFreeze({
+  selectors: [['.cls']],
+  offsetX: 1,
+  offsetY: 1,
+  target: 'main',
+  frame: [0],
+  assertedEvents: [
+    {type: 'navigation', url: 'https://example.com', title: 'Title'},
+  ],
+  value: 'Value',
+  key: 'Enter',
+  operator: '>=',
+  count: 1,
+  expression: 'true',
+  x: 0,
+  y: 0,
+  url: 'https://example.com',
+  timeout: 5000,
+  duration: 50,
+  deviceType: 'mouse',
+  button: 'primary',
+  type: 'click',
+  width: 800,
+  height: 600,
+  deviceScaleFactor: 1,
+  isMobile: false,
+  hasTouch: false,
+  isLandscape: true,
+  download: 1000,
+  upload: 1000,
+  latency: 25,
+  name: 'customParam',
+  parameters: '{}',
+  properties: '{}',
+  attributes: [{name: 'attribute', value: 'value'}],
+  visible: true,
+});
+
+const attributesByType = deepFreeze<{
+  [Type in Models.Schema.StepType]:
+      {required: Exclude<RequiredKeys<StepFor<Type>>, 'type'>[], optional: OptionalKeys<StepFor<Type>>[]};
+}>({
+  [Models.Schema.StepType.Click]: {
+    required: ['selectors', 'offsetX', 'offsetY'],
+    optional: [
+      'assertedEvents',
+      'button',
+      'deviceType',
+      'duration',
+      'frame',
+      'target',
+      'timeout',
+    ],
+  },
+  [Models.Schema.StepType.DoubleClick]: {
+    required: ['offsetX', 'offsetY', 'selectors'],
+    optional: [
+      'assertedEvents',
+      'button',
+      'deviceType',
+      'frame',
+      'target',
+      'timeout',
+    ],
+  },
+  [Models.Schema.StepType.Hover]: {
+    required: ['selectors'],
+    optional: ['assertedEvents', 'frame', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.Change]: {
+    required: ['selectors', 'value'],
+    optional: ['assertedEvents', 'frame', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.KeyDown]: {
+    required: ['key'],
+    optional: ['assertedEvents', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.KeyUp]: {
+    required: ['key'],
+    optional: ['assertedEvents', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.Scroll]: {
+    required: [],
+    optional: ['assertedEvents', 'frame', 'target', 'timeout', 'x', 'y'],
+  },
+  [Models.Schema.StepType.Close]: {
+    required: [],
+    optional: ['assertedEvents', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.Navigate]: {
+    required: ['url'],
+    optional: ['assertedEvents', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.WaitForElement]: {
+    required: ['selectors'],
+    optional: [
+      'assertedEvents',
+      'attributes',
+      'count',
+      'frame',
+      'operator',
+      'properties',
+      'target',
+      'timeout',
+      'visible',
+    ],
+  },
+  [Models.Schema.StepType.WaitForExpression]: {
+    required: ['expression'],
+    optional: ['assertedEvents', 'frame', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.CustomStep]: {
+    required: ['name', 'parameters'],
+    optional: ['assertedEvents', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.EmulateNetworkConditions]: {
+    required: ['download', 'latency', 'upload'],
+    optional: ['assertedEvents', 'target', 'timeout'],
+  },
+  [Models.Schema.StepType.SetViewport]: {
+    required: [
+      'deviceScaleFactor',
+      'hasTouch',
+      'height',
+      'isLandscape',
+      'isMobile',
+      'width',
+    ],
+    optional: ['assertedEvents', 'target', 'timeout'],
+  },
+});
+
+const UIStrings = {
+  /**
+   *@description The text that is disabled when the steps were not saved due to an error. The error message itself is always in English and not translated.
+   *@example {Saving failed} error
+   */
+  notSaved: 'Not saved: {error}',
+  /**
+   *@description The button title that adds a new attribute to the form.
+   *@example {timeout} attributeName
+   */
+  addAttribute: 'Add {attributeName}',
+  /**
+   *@description The title of a button that deletes an attribute from the form.
+   */
+  deleteRow: 'Delete row',
+  /**
+   *@description The title of a button that allows you to select an element on the page and update CSS/ARIA selectors.
+   */
+  selectorPicker: 'Select an element in the page to update selectors',
+  /**
+   *@description The title of a button that adds a new input field for the entry of the frame index. Frame index is the number of the frame within the page's frame tree.
+   */
+  addFrameIndex: 'Add frame index within the frame tree',
+  /**
+   *@description The title of a button that removes a frame index field from the form.
+   */
+  removeFrameIndex: 'Remove frame index',
+  /**
+   *@description The title of a button that adds a field to input a part of a selector in the editor form.
+   */
+  addSelectorPart: 'Add a selector part',
+  /**
+   *@description The title of a button that removes a field to input a part of a selector in the editor form.
+   */
+  removeSelectorPart: 'Remove a selector part',
+  /**
+   *@description The title of a button that adds a field to input a selector in the editor form.
+   */
+  addSelector: 'Add a selector',
+  /**
+   *@description The title of a button that removes a field to input a selector in the editor form.
+   */
+  removeSelector: 'Remove a selector',
+  /**
+   *@description The error message display when a user enters a type in the input not associates with any existing types.
+   */
+  unknownActionType: 'Unknown action type.',
+};
+const str_ = i18n.i18n.registerUIStrings('panels/recorder/components/StepEditor.ts', UIStrings);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+const deleteIconUrl = new URL('../images/delete_icon.svg', import.meta.url).toString();
+const plusIconUrl = new URL('../images/plus_icon.svg', import.meta.url).toString();
+const selectIconUrl = new URL('../images/select_icon.svg', import.meta.url).toString();
+const minusIconUrl = new URL('../images/minus_icon.svg', import.meta.url).toString();
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-recorder-step-editor': StepEditor;
+    'devtools-recorder-selector-picker-button': RecorderSelectorPickerButton;
+  }
+}
+
+export class StepEditedEvent extends Event {
+  static readonly eventName = 'stepedited';
+  data: Models.Schema.Step;
+
+  constructor(step: Models.Schema.Step) {
+    super(StepEditedEvent.eventName, {bubbles: true, composed: true});
+    this.data = step;
+  }
+}
+
+export interface EditorState {
+  type: Models.Schema.StepType;
+  target?: string;
+  selectors?: string[][];
+  frame?: number[];
+  x?: number;
+  y?: number;
+  offsetX?: number;
+  offsetY?: number;
+  key?: string;
+  expression?: string;
+  value?: string;
+  operator?: string;
+  count?: number;
+  assertedEvents?: Models.Schema.AssertedEvent[];
+  url?: string;
+  timeout?: number;
+  button?: string;
+  duration?: number;
+  deviceType?: string;
+  width?: number;
+  height?: number;
+  deviceScaleFactor?: number;
+  isMobile?: boolean;
+  hasTouch?: boolean;
+  isLandscape?: boolean;
+  download?: number;
+  upload?: number;
+  latency?: number;
+  name?: string;
+  parameters?: string;
+  visible?: boolean;
+  properties?: string;
+  attributes?: Array<{name: string, value: string}>;
+}
+
+// Makes use of the fact that JSON values get their undefined values cleaned
+// after stringification.
+const cleanUndefineds = <T>(value: T): T => {
+  return JSON.parse(JSON.stringify(value));
+};
+
+interface Puppeteer {
+  page: Puppeteer.Page;
+  browser: Puppeteer.Browser;
+}
+
+export class EditorState {
+  static #puppeteer: Util.SharedObject.SharedObject<Puppeteer> = new Util.SharedObject.SharedObject(
+      () => Models.RecordingPlayer.RecordingPlayer.connectPuppeteer(),
+      ({browser}) => Models.RecordingPlayer.RecordingPlayer.disconnectPuppeteer(browser));
+
+  static async default(type: Models.Schema.StepType): Promise<DeepImmutable<EditorState>> {
+    const state = {type};
+    const attributes = attributesByType[state.type];
+    let promise: Promise<unknown> = Promise.resolve();
+    for (const attribute of attributes.required) {
+      promise = Promise.all([
+        promise,
+        (async(): Promise<EditorState> => Object.assign(state, {
+          [attribute]: await this.defaultByAttribute(state, attribute),
+        }))(),
+      ]);
+    }
+    await promise;
+    return Object.freeze(state);
+  }
+
+  static async defaultByAttribute<Attribute extends keyof typeof defaultValuesByAttribute>(
+      state: DeepImmutable<EditorState>,
+      attribute: Attribute): Promise<DeepImmutable<typeof defaultValuesByAttribute[Attribute]>>;
+  static async defaultByAttribute(_state: DeepImmutable<EditorState>, attribute: keyof typeof defaultValuesByAttribute):
+      Promise<unknown> {
+    return this.#puppeteer.run(puppeteer => {
+      switch (attribute) {
+        case 'assertedEvents': {
+          return immutableDeepAssign(defaultValuesByAttribute.assertedEvents, new ArrayAssignments({
+                                       0: {
+                                         url: puppeteer.page.url() || defaultValuesByAttribute.assertedEvents[0].url,
+                                       },
+                                     }));
+        }
+        case 'url': {
+          return puppeteer.page.url() || defaultValuesByAttribute.url;
+        }
+        case 'height': {
+          return (
+              puppeteer.page.evaluate(() => (visualViewport as VisualViewport).height) ||
+              defaultValuesByAttribute.height);
+        }
+        case 'width': {
+          return (
+              puppeteer.page.evaluate(() => (visualViewport as VisualViewport).width) ||
+              defaultValuesByAttribute.width);
+        }
+        default: {
+          return defaultValuesByAttribute[attribute];
+        }
+      }
+    });
+  }
+
+  static fromStep(step: DeepImmutable<Models.Schema.Step>): DeepImmutable<EditorState> {
+    const state = structuredClone(step) as EditorState;
+    for (const key of ['parameters', 'properties'] as Array<'properties'>) {
+      if (key in step && step[key] !== undefined) {
+        state[key] = JSON.stringify(step[key]);
+      }
+    }
+    if ('attributes' in step && step.attributes) {
+      state.attributes = [];
+      for (const [name, value] of Object.entries(step.attributes)) {
+        state.attributes.push({name, value});
+      }
+    }
+    if ('selectors' in step) {
+      state.selectors = step.selectors.map(selector => {
+        if (typeof selector === 'string') {
+          return [selector];
+        }
+        return [...selector];
+      });
+    }
+    return deepFreeze(state as EditorState);
+  }
+
+  static toStep(state: DeepImmutable<EditorState>): Models.Schema.Step {
+    const step = structuredClone(state) as Models.Schema.Step;
+    for (const key of ['parameters', 'properties'] as Array<'properties'>) {
+      const value = state[key];
+      if (value) {
+        Object.assign(step, {[key]: JSON.parse(value)});
+      }
+    }
+    if (state.attributes) {
+      if (state.attributes.length !== 0) {
+        const attributes = {};
+        for (const {name, value} of state.attributes) {
+          Object.assign(attributes, {[name]: value});
+        }
+        Object.assign(step, {attributes});
+      } else if ('attributes' in step) {
+        delete step.attributes;
+      }
+    }
+    if (state.selectors) {
+      const selectors = state.selectors.filter(selector => selector.length > 0).map(selector => {
+        if (selector.length === 1) {
+          return selector[0];
+        }
+        return [...selector];
+      });
+      if (selectors.length !== 0) {
+        Object.assign(step, {selectors});
+      } else if ('selectors' in step) {
+        // @ts-expect-error We want to trigger an error in the parsing phase.
+        delete step.selectors;
+      }
+    }
+    if (state.frame && state.frame.length === 0 && 'frame' in step) {
+      delete step.frame;
+    }
+    return cleanUndefineds(Models.SchemaUtils.parseStep(step));
+  }
+}
+
+/**
+ * @fires RequestSelectorAttributeEvent#requestselectorattribute
+ * @fires SelectorPickedEvent#selectorpicked
+ */
+@customElement('devtools-recorder-selector-picker-button')
+class RecorderSelectorPickerButton extends LitElement {
+  static override styles = [stepEditorStyles];
+
+  @property() declare disabled: boolean;
+
+  #picker = new Controllers.SelectorPicker.SelectorPicker(this);
+
+  constructor() {
+    super();
+    this.disabled = false;
+  }
+
+  #handleClickEvent = (event: MouseEvent): void => {
+    event.preventDefault();
+    event.stopPropagation();
+    void this.#picker.toggle();
+  };
+
+  override disconnectedCallback(): void {
+    super.disconnectedCallback();
+    void this.#picker.stop();
+  }
+
+  protected override render(): LitHtml.TemplateResult|undefined {
+    if (this.disabled) {
+      return;
+    }
+    return html`<devtools-button
+      @click=${this.#handleClickEvent}
+      .title=${i18nString(UIStrings.selectorPicker)}
+      class="selector-picker"
+      .size=${Buttons.Button.Size.SMALL}
+      .iconUrl=${selectIconUrl}
+      .active=${this.#picker.active}
+      .variant=${Buttons.Button.Variant.SECONDARY}
+    ></devtools-button>`;
+  }
+}
+
+/**
+ * @fires RequestSelectorAttributeEvent#requestselectorattribute
+ * @fires StepEditedEvent#stepedited
+ */
+@customElement('devtools-recorder-step-editor')
+export class StepEditor extends LitElement {
+  static override styles = [stepEditorStyles];
+
+  @state() private declare state: DeepImmutable<EditorState>;
+  @state() private declare error: string|undefined;
+
+  @property() declare isTypeEditable: boolean;
+  @property() declare disabled: boolean;
+
+  #renderedAttributes: Set<Attribute> = new Set();
+
+  constructor() {
+    super();
+
+    this.state = {type: Models.Schema.StepType.WaitForElement};
+
+    this.isTypeEditable = true;
+    this.disabled = false;
+  }
+
+  protected override createRenderRoot(): Element|ShadowRoot {
+    const root = super.createRenderRoot();
+    root.addEventListener('keydown', this.#handleKeyDownEvent);
+    return root;
+  }
+
+  set step(step: DeepImmutable<Models.Schema.Step>) {
+    this.state = deepFreeze(EditorState.fromStep(step));
+    this.error = undefined;
+  }
+
+  #commit(updatedState: DeepImmutable<EditorState>): void {
+    try {
+      this.dispatchEvent(new StepEditedEvent(EditorState.toStep(updatedState)));
+      // Note we don't need to update this variable since it will come from up
+      // the tree, but processing up the tree is asynchronous implying we cannot
+      // reliably know when the state will come back down. Since we need to
+      // focus the DOM elements that may be created as a result of this new
+      // state, we set it here for waiting on the updateComplete promise later.
+      this.state = updatedState;
+    } catch (error) {
+      this.error = error.message;
+    }
+  }
+
+  #handleSelectorPickedEvent = (event: Controllers.SelectorPicker.SelectorPickedEvent): void => {
+    event.preventDefault();
+    event.stopPropagation();
+
+    this.#commit(immutableDeepAssign(this.state, {
+      target: event.data.target,
+      frame: event.data.frame,
+      selectors: event.data.selectors.map(selector => typeof selector === 'string' ? [selector] : selector),
+      offsetX: event.data.offsetX,
+      offsetY: event.data.offsetY,
+    }));
+  };
+
+  #handleAddOrRemoveClick =
+      (assignments: DeepImmutable<DeepPartial<Assignments<EditorState>>>, query: string,
+       metric: Host.UserMetrics.RecordingEdited): ((event: Event) => void) => event => {
+        event.preventDefault();
+        event.stopPropagation();
+
+        this.#commit(immutableDeepAssign(this.state, assignments));
+
+        this.#ensureFocus(query);
+
+        if (metric) {
+          Host.userMetrics.recordingEdited(metric);
+        }
+      };
+
+  #handleKeyDownEvent = (event: Event): void => {
+    assert(event instanceof KeyboardEvent);
+    if (event.target instanceof RecorderInput && event.key === 'Enter') {
+      event.preventDefault();
+      event.stopPropagation();
+      const elements = this.renderRoot.querySelectorAll('devtools-recorder-input');
+      const element = [...elements].findIndex(value => value === event.target);
+      if (element >= 0 && element + 1 < elements.length) {
+        elements[element + 1].focus();
+      } else {
+        event.target.blur();
+      }
+    }
+  };
+
+  #handleInputBlur = <A extends Attribute>(opts: {
+    attribute: A,
+    // If there are not assignments, then we should ignore the event.
+    from(this: StepEditor, value: DataType<A>): DeepImmutable<DeepPartial<Assignments<EditorState>>>|undefined,
+    metric: Host.UserMetrics.RecordingEdited,
+  }): ((event: Event) => void) => event => {
+    assert(event.target instanceof RecorderInput);
+    if (event.target.disabled) {
+      return;
+    }
+
+    const dataType = dataTypeByAttribute[opts.attribute];
+    const value = typeConverters[dataType](event.target.value) as DataType<A>;
+    const assignments = opts.from.bind(this)(value);
+    if (!assignments) {
+      return;
+    }
+    this.#commit(immutableDeepAssign(this.state, assignments));
+
+    if (opts.metric) {
+      Host.userMetrics.recordingEdited(opts.metric);
+    }
+  };
+
+  #handleTypeInputBlur = async(event: Event): Promise<void> => {
+    assert(event.target instanceof RecorderInput);
+    if (event.target.disabled) {
+      return;
+    }
+
+    const value = event.target.value as Models.Schema.StepType;
+    if (value === this.state.type) {
+      return;
+    }
+    if (!Object.values(Models.Schema.StepType).includes(value)) {
+      this.error = i18nString(UIStrings.unknownActionType);
+      return;
+    }
+    this.#commit(await EditorState.default(value));
+    Host.userMetrics.recordingEdited(Host.UserMetrics.RecordingEdited.TypeChanged);
+  };
+
+  #handleAddRowClickEvent = async(event: MouseEvent): Promise<void> => {
+    event.preventDefault();
+    event.stopPropagation();
+
+    const attribute = (event.target as HTMLElement).dataset.attribute as Attribute;
+
+    this.#commit(immutableDeepAssign(this.state, {
+      [attribute]: await EditorState.defaultByAttribute(this.state, attribute),
+    }));
+
+    this.#ensureFocus(`[data-attribute=${attribute}].attribute devtools-recorder-input`);
+  };
+
+  #renderInlineButton(opts: {class: string, title: string, iconUrl: string, onClick: (event: MouseEvent) => void}):
+      LitHtml.TemplateResult|undefined {
+    if (this.disabled) {
+      return;
+    }
+    return html`
+      <devtools-button
+        title=${opts.title}
+        .size=${Buttons.Button.Size.SMALL}
+        .iconUrl=${opts.iconUrl}
+        .variant=${Buttons.Button.Variant.SECONDARY}
+        class="inline-button ${opts.class}"
+        @click=${opts.onClick}
+      ></devtools-button>
+    `;
+  }
+
+  #renderDeleteButton(attribute: Attribute): LitHtml.TemplateResult|undefined {
+    if (this.disabled) {
+      return;
+    }
+
+    const attributes = attributesByType[this.state.type];
+    const optional = [...attributes.optional].includes(attribute as typeof attributes.optional[number]);
+    if (!optional || this.disabled) {
+      return;
+    }
+
+    // clang-format off
+    return html`<devtools-button
+      .size=${Buttons.Button.Size.SMALL}
+      .iconUrl=${deleteIconUrl}
+      .variant=${Buttons.Button.Variant.SECONDARY}
+      .title=${i18nString(UIStrings.deleteRow)}
+      class="inline-button delete-row"
+      data-attribute=${attribute}
+      @click=${(event: MouseEvent): void => {
+        event.preventDefault();
+        event.stopPropagation();
+
+        this.#commit(
+          immutableDeepAssign(this.state, { [attribute]: undefined }),
+        );
+      }}
+    ></devtools-button>`;
+    // clang-format on
+  }
+
+  #renderTypeRow(editable: boolean): LitHtml.TemplateResult {
+    this.#renderedAttributes.add('type');
+    // clang-format off
+    return html`<div class="row attribute" data-attribute="type">
+      <div>type<span class="separator">:</span></div>
+      <devtools-recorder-input
+        .disabled=${!editable || this.disabled}
+        .options=${Object.values(Models.Schema.StepType)}
+        .placeholder=${defaultValuesByAttribute.type}
+        .value=${live(this.state.type)}
+        @blur=${this.#handleTypeInputBlur}
+      ></devtools-recorder-input>
+    </div>`;
+    // clang-format on
+  }
+
+  #renderRow(attribute: Attribute): LitHtml.TemplateResult|undefined {
+    this.#renderedAttributes.add(attribute);
+    const attributeValue = this.state[attribute]?.toString();
+    if (attributeValue === undefined) {
+      return;
+    }
+    // clang-format off
+    return html`<div class="row attribute" data-attribute=${attribute}>
+      <div>${attribute}<span class="separator">:</span></div>
+      <devtools-recorder-input
+        .disabled=${this.disabled}
+        .placeholder=${defaultValuesByAttribute[attribute].toString()}
+        .value=${live(attributeValue)}
+        .mimeType=${((): string => {
+          switch (attribute) {
+            case 'expression':
+              return 'text/javascript';
+            case 'properties':
+              return 'application/json';
+            default:
+              return '';
+          }
+        })()}
+        @blur=${this.#handleInputBlur({
+      attribute,
+      from(value) {
+        if (this.state[attribute] === undefined) {
+          return;
+        }
+        switch (attribute) {
+          case 'properties':
+            Host.userMetrics.recordingAssertion(Host.UserMetrics.RecordingAssertion.PropertyAssertionEdited);
+            break;
+        }
+        return {[attribute]: value};
+      },
+      metric: Host.UserMetrics.RecordingEdited.OtherEditing,
+    })}
+      ></devtools-recorder-input>
+      ${this.#renderDeleteButton(attribute)}
+    </div>`;
+    // clang-format on
+  }
+
+  #renderFrameRow(): LitHtml.TemplateResult|undefined {
+    this.#renderedAttributes.add('frame');
+    if (this.state.frame === undefined) {
+      return;
+    }
+    // clang-format off
+    return html`
+      <div class="attribute" data-attribute="frame">
+        <div class="row">
+          <div>frame<span class="separator">:</span></div>
+          ${this.#renderDeleteButton('frame')}
+        </div>
+        ${this.state.frame.map((frame, index, frames) => {
+          return html`
+            <div class="padded row">
+              <devtools-recorder-input
+                .disabled=${this.disabled}
+                .placeholder=${defaultValuesByAttribute.frame[0].toString()}
+                .value=${live(frame.toString())}
+                data-path=${`frame.${index}`}
+                @blur=${this.#handleInputBlur({
+                  attribute: 'frame',
+                  from(value) {
+                    if (this.state.frame?.[index] === undefined) {
+                      return;
+                    }
+                    return {
+                      frame: new ArrayAssignments({ [index]: value }),
+                    };
+                  },
+                  metric: Host.UserMetrics.RecordingEdited.OtherEditing,
+                })}
+              ></devtools-recorder-input>
+              ${this.#renderInlineButton({
+                class: 'add-frame',
+                title: i18nString(UIStrings.addFrameIndex),
+                iconUrl: plusIconUrl,
+                onClick: this.#handleAddOrRemoveClick(
+                  {
+                    frame: new ArrayAssignments({
+                      [index + 1]: new InsertAssignment(
+                        defaultValuesByAttribute.frame[0],
+                      ),
+                    }),
+                  },
+                  `devtools-recorder-input[data-path="frame.${index + 1}"]`,
+                  Host.UserMetrics.RecordingEdited.OtherEditing,
+                ),
+              })}
+              ${this.#renderInlineButton({
+                class: 'remove-frame',
+                title: i18nString(UIStrings.removeFrameIndex),
+                iconUrl: minusIconUrl,
+                onClick: this.#handleAddOrRemoveClick(
+                  {
+                    frame: new ArrayAssignments({ [index]: undefined }),
+                  },
+                  `devtools-recorder-input[data-path="frame.${Math.min(
+                    index,
+                    frames.length - 2,
+                  )}"]`,
+                  Host.UserMetrics.RecordingEdited.OtherEditing,
+                ),
+              })}
+            </div>
+          `;
+        })}
+      </div>
+    `;
+    // clang-format on
+  }
+
+  #renderSelectorsRow(): LitHtml.TemplateResult|undefined {
+    this.#renderedAttributes.add('selectors');
+    if (this.state.selectors === undefined) {
+      return;
+    }
+    // clang-format off
+    return html`<div class="attribute" data-attribute="selectors">
+      <div class="row">
+        <div>selectors<span class="separator">:</span></div>
+        <devtools-recorder-selector-picker-button
+          @selectorpicked=${this.#handleSelectorPickedEvent}
+          .disabled=${this.disabled}
+        ></devtools-recorder-selector-picker-button>
+        ${this.#renderDeleteButton('selectors')}
+      </div>
+      ${this.state.selectors.map((selector, index, selectors) => {
+        return html`<div class="padded row" data-selector-path=${index}>
+            <div>selector #${index + 1}<span class="separator">:</span></div>
+            ${this.#renderInlineButton({
+              class: 'add-selector',
+              title: i18nString(UIStrings.addSelector),
+              iconUrl: plusIconUrl,
+              onClick: this.#handleAddOrRemoveClick(
+                {
+                  selectors: new ArrayAssignments({
+                    [index + 1]: new InsertAssignment(
+                      structuredClone(defaultValuesByAttribute.selectors[0]),
+                    ),
+                  }),
+                },
+                `devtools-recorder-input[data-path="selectors.${index + 1}.0"]`,
+                Host.UserMetrics.RecordingEdited.SelectorAdded,
+              ),
+            })}
+            ${this.#renderInlineButton({
+              class: 'remove-selector',
+              title: i18nString(UIStrings.removeSelector),
+              iconUrl: minusIconUrl,
+              onClick: this.#handleAddOrRemoveClick(
+                { selectors: new ArrayAssignments({ [index]: undefined }) },
+                `devtools-recorder-input[data-path="selectors.${Math.min(
+                  index,
+                  selectors.length - 2,
+                )}.0"]`,
+                Host.UserMetrics.RecordingEdited.SelectorRemoved,
+              ),
+            })}
+          </div>
+          ${selector.map((part, partIndex, parts) => {
+            return html`<div
+              class="double padded row"
+              data-selector-path="${index}.${partIndex}"
+            >
+              <devtools-recorder-input
+                .disabled=${this.disabled}
+                .placeholder=${defaultValuesByAttribute.selectors[0][0]}
+                .value=${live(part)}
+                data-path=${`selectors.${index}.${partIndex}`}
+                @blur=${this.#handleInputBlur({
+                  attribute: 'selectors',
+                  from(value) {
+                    if (
+                      this.state.selectors?.[index]?.[partIndex] === undefined
+                    ) {
+                      return;
+                    }
+                    return {
+                      selectors: new ArrayAssignments({
+                        [index]: new ArrayAssignments({
+                          [partIndex]: value,
+                        }),
+                      }),
+                    };
+                  },
+                  metric: Host.UserMetrics.RecordingEdited.SelectorPartEdited,
+                })}
+              ></devtools-recorder-input>
+              ${this.#renderInlineButton({
+                class: 'add-selector-part',
+                title: i18nString(UIStrings.addSelectorPart),
+                iconUrl: plusIconUrl,
+                onClick: this.#handleAddOrRemoveClick(
+                  {
+                    selectors: new ArrayAssignments({
+                      [index]: new ArrayAssignments({
+                        [partIndex + 1]: new InsertAssignment(
+                          defaultValuesByAttribute.selectors[0][0],
+                        ),
+                      }),
+                    }),
+                  },
+                  `devtools-recorder-input[data-path="selectors.${index}.${
+                    partIndex + 1
+                  }"]`,
+                  Host.UserMetrics.RecordingEdited.SelectorPartAdded,
+                ),
+              })}
+              ${this.#renderInlineButton({
+                class: 'remove-selector-part',
+                title: i18nString(UIStrings.removeSelectorPart),
+                iconUrl: minusIconUrl,
+                onClick: this.#handleAddOrRemoveClick(
+                  {
+                    selectors: new ArrayAssignments({
+                      [index]: new ArrayAssignments({
+                        [partIndex]: undefined,
+                      }),
+                    }),
+                  },
+                  `devtools-recorder-input[data-path="selectors.${index}.${Math.min(
+                    partIndex,
+                    parts.length - 2,
+                  )}"]`,
+                  Host.UserMetrics.RecordingEdited.SelectorPartRemoved,
+                ),
+              })}
+            </div>`;
+          })}`;
+      })}
+    </div>`;
+    // clang-format on
+  }
+
+  #renderAssertedEvents(): LitHtml.TemplateResult|undefined {
+    this.#renderedAttributes.add('assertedEvents');
+    if (this.state.assertedEvents === undefined) {
+      return;
+    }
+    // clang-format off
+    return html`<div class="attribute" data-attribute="assertedEvents">
+      <div class="row">
+        <div>asserted events<span class="separator">:</span></div>
+        ${this.#renderDeleteButton('assertedEvents')}
+      </div>
+      ${this.state.assertedEvents.map((event, index) => {
+        return html` <div class="padded row">
+            <div>type<span class="separator">:</span></div>
+            <div>${event.type}</div>
+          </div>
+          <div class="padded row">
+            <div>title<span class="separator">:</span></div>
+            <devtools-recorder-input
+              .disabled=${this.disabled}
+              .placeholder=${defaultValuesByAttribute.assertedEvents[0].title}
+              .value=${live(event.title ?? '')}
+              @blur=${this.#handleInputBlur({
+                attribute: 'assertedEvents',
+                from(value) {
+                  if (this.state.assertedEvents?.[index]?.title === undefined) {
+                    return;
+                  }
+                  return {
+                    assertedEvents: new ArrayAssignments({
+                      [index]: { title: value },
+                    }),
+                  };
+                },
+                metric: Host.UserMetrics.RecordingEdited.OtherEditing,
+              })}
+            ></devtools-recorder-input>
+          </div>
+          <div class="padded row">
+            <div>url<span class="separator">:</span></div>
+            <devtools-recorder-input
+              .disabled=${this.disabled}
+              .placeholder=${defaultValuesByAttribute.assertedEvents[0].url}
+              .value=${live(event.url ?? '')}
+              @blur=${this.#handleInputBlur({
+                attribute: 'url',
+                from(value) {
+                  if (this.state.assertedEvents?.[index]?.url === undefined) {
+                    return;
+                  }
+                  return {
+                    assertedEvents: new ArrayAssignments({
+                      [index]: { url: value },
+                    }),
+                  };
+                },
+                metric: Host.UserMetrics.RecordingEdited.OtherEditing,
+              })}
+            ></devtools-recorder-input>
+          </div>`;
+      })}
+    </div> `;
+    // clang-format on
+  }
+
+  #renderAttributesRow(): LitHtml.TemplateResult|undefined {
+    this.#renderedAttributes.add('attributes');
+    if (this.state.attributes === undefined) {
+      return;
+    }
+    // clang-format off
+    return html`<div class="attribute" data-attribute="attributes">
+      <div class="row">
+        <div>attributes<span class="separator">:</span></div>
+        ${this.#renderDeleteButton('attributes')}
+      </div>
+      ${this.state.attributes.map(({ name, value }, index, attributes) => {
+        return html`<div class="padded row">
+          <devtools-recorder-input
+            .disabled=${this.disabled}
+            .placeholder=${defaultValuesByAttribute.attributes[0].name}
+            .value=${live(name)}
+            data-path=${`attributes.${index}.name`}
+            @blur=${this.#handleInputBlur({
+              attribute: 'attributes',
+              from(name) {
+                if (this.state.attributes?.[index]?.name === undefined) {
+                  return;
+                }
+                Host.userMetrics.recordingAssertion(
+                  Host.UserMetrics.RecordingAssertion.AttributeAssertionEdited,
+                );
+                return {
+                  attributes: new ArrayAssignments({ [index]: { name } }),
+                };
+              },
+              metric: Host.UserMetrics.RecordingEdited.OtherEditing,
+            })}
+          ></devtools-recorder-input>
+          <span class="separator">:</span>
+          <devtools-recorder-input
+            .disabled=${this.disabled}
+            .placeholder=${defaultValuesByAttribute.attributes[0].value}
+            .value=${live(value)}
+            data-path=${`attributes.${index}.value`}
+            @blur=${this.#handleInputBlur({
+              attribute: 'attributes',
+              from(value) {
+                if (this.state.attributes?.[index]?.value === undefined) {
+                  return;
+                }
+                Host.userMetrics.recordingAssertion(
+                  Host.UserMetrics.RecordingAssertion.AttributeAssertionEdited,
+                );
+                return {
+                  attributes: new ArrayAssignments({ [index]: { value } }),
+                };
+              },
+              metric: Host.UserMetrics.RecordingEdited.OtherEditing,
+            })}
+          ></devtools-recorder-input>
+          ${this.#renderInlineButton({
+            class: 'add-attribute-assertion',
+            title: i18nString(UIStrings.addSelectorPart),
+            iconUrl: plusIconUrl,
+            onClick: this.#handleAddOrRemoveClick(
+              {
+                attributes: new ArrayAssignments({
+                  [index + 1]: new InsertAssignment(
+                    ((): { name: string, value: string } => {
+                      {
+                        const names = new Set(
+                          attributes.map(({ name }) => name),
+                        );
+                        const defaultAttribute =
+                          defaultValuesByAttribute.attributes[0];
+                        let name = defaultAttribute.name;
+                        let i = 0;
+                        while (names.has(name)) {
+                          ++i;
+                          name = `${defaultAttribute.name}-${i}`;
+                        }
+                        return { ...defaultAttribute, name };
+                      }
+                    })(),
+                  ),
+                }),
+              },
+              `devtools-recorder-input[data-path="attributes.${
+                index + 1
+              }.name"]`,
+              Host.UserMetrics.RecordingEdited.OtherEditing,
+            ),
+          })}
+          ${this.#renderInlineButton({
+            class: 'remove-attribute-assertion',
+            title: i18nString(UIStrings.removeSelectorPart),
+            iconUrl: minusIconUrl,
+            onClick: this.#handleAddOrRemoveClick(
+              { attributes: new ArrayAssignments({ [index]: undefined }) },
+              `devtools-recorder-input[data-path="attributes.${Math.min(
+                index,
+                attributes.length - 2,
+              )}.value"]`,
+              Host.UserMetrics.RecordingEdited.OtherEditing,
+            ),
+          })}
+        </div>`;
+      })}
+    </div>`;
+    // clang-format on
+  }
+
+  #renderAddRowButtons(): Array<LitHtml.TemplateResult|undefined> {
+    const attributes = attributesByType[this.state.type];
+    return [...attributes.optional].filter(attr => this.state[attr] === undefined).map(attr => {
+      // clang-format off
+        return html`<devtools-button
+          .variant=${Buttons.Button.Variant.SECONDARY}
+          class="add-row"
+          data-attribute=${attr}
+          @click=${this.#handleAddRowClickEvent}
+        >
+          ${i18nString(UIStrings.addAttribute, {
+            attributeName: attr,
+          })}
+        </devtools-button>`;
+      // clang-format on
+    });
+  }
+
+  #ensureFocus = (query: string): void => {
+    void this.updateComplete.then(() => {
+      const node = this.renderRoot.querySelector<HTMLElement>(query);
+      node?.focus();
+    });
+  };
+
+  protected override render(): LitHtml.TemplateResult {
+    this.#renderedAttributes = new Set();
+
+    // clang-format off
+    const result = html`
+      <div class="wrapper">
+        ${this.#renderTypeRow(this.isTypeEditable)} ${this.#renderRow('target')}
+        ${this.#renderFrameRow()} ${this.#renderSelectorsRow()}
+        ${this.#renderRow('deviceType')} ${this.#renderRow('button')}
+        ${this.#renderRow('url')} ${this.#renderRow('x')}
+        ${this.#renderRow('y')} ${this.#renderRow('offsetX')}
+        ${this.#renderRow('offsetY')} ${this.#renderRow('value')}
+        ${this.#renderRow('key')} ${this.#renderRow('operator')}
+        ${this.#renderRow('count')} ${this.#renderRow('expression')}
+        ${this.#renderRow('duration')} ${this.#renderAssertedEvents()}
+        ${this.#renderRow('timeout')} ${this.#renderRow('width')}
+        ${this.#renderRow('height')} ${this.#renderRow('deviceScaleFactor')}
+        ${this.#renderRow('isMobile')} ${this.#renderRow('hasTouch')}
+        ${this.#renderRow('isLandscape')} ${this.#renderRow('download')}
+        ${this.#renderRow('upload')} ${this.#renderRow('latency')}
+        ${this.#renderRow('name')} ${this.#renderRow('parameters')}
+        ${this.#renderRow('visible')} ${this.#renderRow('properties')}
+        ${this.#renderAttributesRow()}
+        ${this.error
+          ? html`
+              <div class="error">
+                ${i18nString(UIStrings.notSaved, {
+                  error: this.error,
+                })}
+              </div>
+            `
+          : undefined}
+        ${!this.disabled
+          ? html`<div
+              class="row-buttons wrapped gap row regular-font no-margin"
+            >
+              ${this.#renderAddRowButtons()}
+            </div>`
+          : undefined}
+      </div>
+    `;
+
+    // clang-format on
+    Platform.DCHECK(() => {
+      for (const key of Object.keys(dataTypeByAttribute)) {
+        if (!this.#renderedAttributes.has(key as Attribute)) {
+          return false;
+        }
+      }
+      return true;
+    }, 'One of the editable attributes does not have UI');
+
+    return result;
+  }
+}
diff --git a/front_end/panels/recorder/components/StepView.ts b/front_end/panels/recorder/components/StepView.ts
new file mode 100644
index 0000000..7271af3
--- /dev/null
+++ b/front_end/panels/recorder/components/StepView.ts
@@ -0,0 +1,869 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as i18n from '../../../core/i18n/i18n.js';
+import * as Buttons from '../../../ui/components/buttons/buttons.js';
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as IconButton from '../../../ui/components/icon_button/icon_button.js';
+import * as UI from '../../../ui/legacy/legacy.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+import * as Menus from '../../../ui/components/menus/menus.js';
+
+import type * as Converters from '../converters/converters.js';
+import * as Models from '../models/models.js';
+
+import stepViewStyles from './stepView.css.js';
+
+import {type StepEditedEvent} from './StepEditor.js';
+
+import {
+  TimelineSection,
+  type TimelineSectionData,
+} from './TimelineSection.js';
+
+const deployMenuArrow = new URL(
+                            '../images/select-arrow-icon.svg',
+                            import.meta.url,
+                            )
+                            .toString();
+
+const UIStrings = {
+  /**
+   *@description Title for the step type that configures the viewport
+   */
+  setViewportClickTitle: 'Set viewport',
+  /**
+   *@description Title for the customStep step type
+   */
+  customStepTitle: 'Custom step',
+  /**
+   *@description Title for the click step type
+   */
+  clickStepTitle: 'Click',
+  /**
+   *@description Title for the double click step type
+   */
+  doubleClickStepTitle: 'Double click',
+  /**
+   *@description Title for the hover step type
+   */
+  hoverStepTitle: 'Hover',
+  /**
+   *@description Title for the emulateNetworkConditions step type
+   */
+  emulateNetworkConditionsStepTitle: 'Emulate network conditions',
+  /**
+   *@description Title for the change step type
+   */
+  changeStepTitle: 'Change',
+  /**
+   *@description Title for the close step type
+   */
+  closeStepTitle: 'Close',
+  /**
+   *@description Title for the scroll step type
+   */
+  scrollStepTitle: 'Scroll',
+  /**
+   *@description Title for the key up step type. `up` refers to the state of the keyboard key: it's released, i.e., up. It does not refer to the down arrow key specifically.
+   */
+  keyUpStepTitle: 'Key up',
+  /**
+   *@description Title for the navigate step type
+   */
+  navigateStepTitle: 'Navigate',
+  /**
+   *@description Title for the key down step type. `down` refers to the state of the keyboard key: it's pressed, i.e., down. It does not refer to the down arrow key specifically.
+   */
+  keyDownStepTitle: 'Key down',
+  /**
+   *@description Title for the waitForElement step type
+   */
+  waitForElementStepTitle: 'Wait for element',
+  /**
+   *@description Title for the waitForExpression step type
+   */
+  waitForExpressionStepTitle: 'Wait for expression',
+  /**
+   *@description Title for elements with role button
+   */
+  elementRoleButton: 'Button',
+  /**
+   *@description Title for elements with role input
+   */
+  elementRoleInput: 'Input',
+  /**
+   *@description Default title for elements without a specific role
+   */
+  elementRoleFallback: 'Element',
+  /**
+   *@description The title of the button in the step's context menu that adds a new step before the current one.
+   */
+  addStepBefore: 'Add step before',
+  /**
+   *@description The title of the button in the step's context menu that adds a new step after the current one.
+   */
+  addStepAfter: 'Add step after',
+  /**
+   *@description The title of the button in the step's context menu that removes the step.
+   */
+  removeStep: 'Remove step',
+  /**
+   *@description The title of the button that open the step's context menu.
+   */
+  openStepActions: 'Open step actions',
+  /**
+   *@description The title of the button in the step's context menu that adds a breakpoint.
+   */
+  addBreakpoint: 'Add breakpoint',
+  /**
+   *@description The title of the button in the step's context menu that removes a breakpoint.
+   */
+  removeBreakpoint: 'Remove breakpoint',
+  /**
+   * @description A menu item item in the context menu that expands another menu which list all
+   * the formats the user can copy the recording as.
+   */
+  copyAs: 'Copy as',
+  /**
+   * @description The title of the menu group that holds actions on recording steps.
+   */
+  stepManagement: 'Manage steps',
+  /**
+   * @description The title of the menu group that holds actions related to breakpoints.
+   */
+  breakpoints: 'Breakpoints',
+};
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/components/StepView.ts',
+    UIStrings,
+);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-step-view': StepView;
+  }
+}
+
+export const enum State {
+  Default = 'default',
+  Success = 'success',
+  Current = 'current',
+  Outstanding = 'outstanding',
+  Error = 'error',
+  Stopped = 'stopped',
+}
+
+export interface StepViewData {
+  state: State;
+  step?: Models.Schema.Step;
+  section?: Models.Section.Section;
+  error?: Error;
+  hasBreakpoint: boolean;
+  isEndOfGroup: boolean;
+  isStartOfGroup: boolean;
+  isFirstSection: boolean;
+  isLastSection: boolean;
+  stepIndex: number;
+  sectionIndex: number;
+  isRecording: boolean;
+  isPlaying: boolean;
+  removable: boolean;
+  builtInConverters: Converters.Converter.Converter[];
+  extensionConverters: Converters.Converter.Converter[];
+  isSelected: boolean;
+  recorderSettings: Models.RecorderSettings.RecorderSettings;
+}
+
+export class CaptureSelectorsEvent extends Event {
+  static readonly eventName = 'captureselectors';
+  data: Models.Schema.StepWithSelectors&Partial<Models.Schema.ClickAttributes>;
+
+  constructor(
+      step: Models.Schema.StepWithSelectors&Partial<Models.Schema.ClickAttributes>,
+  ) {
+    super(CaptureSelectorsEvent.eventName, {bubbles: true, composed: true});
+    this.data = step;
+  }
+}
+
+export class StopSelectorsCaptureEvent extends Event {
+  static readonly eventName = 'stopselectorscapture';
+  constructor() {
+    super(StopSelectorsCaptureEvent.eventName, {
+      bubbles: true,
+      composed: true,
+    });
+  }
+}
+
+export class CopyStepEvent extends Event {
+  static readonly eventName = 'copystep';
+  step: Models.Schema.Step;
+  constructor(step: Models.Schema.Step) {
+    super(CopyStepEvent.eventName, {bubbles: true, composed: true});
+    this.step = step;
+  }
+}
+
+export class StepChanged extends Event {
+  static readonly eventName = 'stepchanged';
+  currentStep: Models.Schema.Step;
+  newStep: Models.Schema.Step;
+
+  constructor(currentStep: Models.Schema.Step, newStep: Models.Schema.Step) {
+    super(StepChanged.eventName, {bubbles: true, composed: true});
+    this.currentStep = currentStep;
+    this.newStep = newStep;
+  }
+}
+
+export const enum AddStepPosition {
+  BEFORE = 'before',
+  AFTER = 'after',
+}
+
+export class AddStep extends Event {
+  static readonly eventName = 'addstep';
+  position: AddStepPosition;
+  stepOrSection: Models.Schema.Step|Models.Section.Section;
+
+  constructor(
+      stepOrSection: Models.Schema.Step|Models.Section.Section,
+      position: AddStepPosition,
+  ) {
+    super(AddStep.eventName, {bubbles: true, composed: true});
+    this.stepOrSection = stepOrSection;
+    this.position = position;
+  }
+}
+
+export class RemoveStep extends Event {
+  static readonly eventName = 'removestep';
+  step: Models.Schema.Step;
+
+  constructor(step: Models.Schema.Step) {
+    super(RemoveStep.eventName, {bubbles: true, composed: true});
+    this.step = step;
+  }
+}
+
+export class AddBreakpointEvent extends Event {
+  static readonly eventName = 'addbreakpoint';
+  index: number;
+
+  constructor(index: number) {
+    super(AddBreakpointEvent.eventName, {bubbles: true, composed: true});
+    this.index = index;
+  }
+}
+
+export class RemoveBreakpointEvent extends Event {
+  static readonly eventName = 'removebreakpoint';
+  index: number;
+
+  constructor(index: number) {
+    super(RemoveBreakpointEvent.eventName, {bubbles: true, composed: true});
+    this.index = index;
+  }
+}
+
+const actionsMenuIconUrl = new URL(
+                               '../images/more_icon.svg',
+                               import.meta.url,
+                               )
+                               .toString();
+const COPY_ACTION_PREFIX = 'copy-step-as-';
+
+type Action = {
+  id: string,
+  label: string,
+  group: string,
+  groupTitle: string,
+};
+
+export class StepView extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-step-view`;
+
+  readonly #shadow = this.attachShadow({mode: 'open'});
+
+  #step?: Models.Schema.Step;
+  #section?: Models.Section.Section;
+  #state = State.Default;
+  #error?: Error;
+  #showDetails: boolean = false;
+  #isEndOfGroup: boolean = false;
+  #isStartOfGroup: boolean = false;
+  #stepIndex = 0;
+  #sectionIndex = 0;
+  #isFirstSection: boolean = false;
+  #isLastSection: boolean = false;
+  #isRecording = false;
+  #isPlaying = false;
+  #actionsMenuButton?: Buttons.Button.Button;
+  #actionsMenuExpanded = false;
+  #isVisible = false;
+  #observer: IntersectionObserver = new IntersectionObserver(result => {
+    this.#isVisible = result[0].isIntersecting;
+  });
+  #hasBreakpoint = false;
+  #removable = true;
+  #builtInConverters?: Converters.Converter.Converter[];
+  #extensionConverters?: Converters.Converter.Converter[];
+  #isSelected = false;
+  #recorderSettings?: Models.RecorderSettings.RecorderSettings;
+
+  set data(data: StepViewData) {
+    const prevState = this.#state;
+    this.#step = data.step;
+    this.#section = data.section;
+    this.#state = data.state;
+    this.#error = data.error;
+    this.#isEndOfGroup = data.isEndOfGroup;
+    this.#isStartOfGroup = data.isStartOfGroup;
+    this.#stepIndex = data.stepIndex;
+    this.#sectionIndex = data.sectionIndex;
+    this.#isFirstSection = data.isFirstSection;
+    this.#isLastSection = data.isLastSection;
+    this.#isRecording = data.isRecording;
+    this.#isPlaying = data.isPlaying;
+    this.#hasBreakpoint = data.hasBreakpoint;
+    this.#removable = data.removable;
+    this.#builtInConverters = data.builtInConverters;
+    this.#extensionConverters = data.extensionConverters;
+    this.#isSelected = data.isSelected;
+    this.#recorderSettings = data.recorderSettings;
+
+    this.#render();
+
+    if (this.#state !== prevState && this.#state === 'current' && !this.#isVisible) {
+      this.scrollIntoView();
+    }
+  }
+
+  get step(): Models.Schema.Step|undefined {
+    return this.#step;
+  }
+
+  get section(): Models.Section.Section|undefined {
+    return this.#section;
+  }
+
+  connectedCallback(): void {
+    this.#shadow.adoptedStyleSheets = [stepViewStyles];
+    this.#observer.observe(this);
+    this.#render();
+  }
+
+  disconnectedCallback(): void {
+    this.#observer.unobserve(this);
+  }
+
+  #toggleShowDetails(): void {
+    this.#showDetails = !this.#showDetails;
+    this.#render();
+  }
+
+  #onToggleShowDetailsKeydown(event: Event): void {
+    const keyboardEvent = event as KeyboardEvent;
+    if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
+      this.#toggleShowDetails();
+      event.stopPropagation();
+      event.preventDefault();
+    }
+  }
+
+  #getStepTypeTitle(): string|LitHtml.TemplateResult {
+    if (this.#section) {
+      return this.#section.title ? this.#section.title : LitHtml.html`<span class="fallback">(No Title)</span>`;
+    }
+    if (!this.#step) {
+      throw new Error('Missing both step and section');
+    }
+    switch (this.#step.type) {
+      case Models.Schema.StepType.CustomStep:
+        return i18nString(UIStrings.customStepTitle);
+      case Models.Schema.StepType.SetViewport:
+        return i18nString(UIStrings.setViewportClickTitle);
+      case Models.Schema.StepType.Click:
+        return i18nString(UIStrings.clickStepTitle);
+      case Models.Schema.StepType.DoubleClick:
+        return i18nString(UIStrings.doubleClickStepTitle);
+      case Models.Schema.StepType.Hover:
+        return i18nString(UIStrings.hoverStepTitle);
+      case Models.Schema.StepType.EmulateNetworkConditions:
+        return i18nString(UIStrings.emulateNetworkConditionsStepTitle);
+      case Models.Schema.StepType.Change:
+        return i18nString(UIStrings.changeStepTitle);
+      case Models.Schema.StepType.Close:
+        return i18nString(UIStrings.closeStepTitle);
+      case Models.Schema.StepType.Scroll:
+        return i18nString(UIStrings.scrollStepTitle);
+      case Models.Schema.StepType.KeyUp:
+        return i18nString(UIStrings.keyUpStepTitle);
+      case Models.Schema.StepType.KeyDown:
+        return i18nString(UIStrings.keyDownStepTitle);
+      case Models.Schema.StepType.WaitForElement:
+        return i18nString(UIStrings.waitForElementStepTitle);
+      case Models.Schema.StepType.WaitForExpression:
+        return i18nString(UIStrings.waitForExpressionStepTitle);
+      case Models.Schema.StepType.Navigate:
+        return i18nString(UIStrings.navigateStepTitle);
+    }
+  }
+
+  #getElementRoleTitle(role: string): string {
+    switch (role) {
+      case 'button':
+        return i18nString(UIStrings.elementRoleButton);
+      case 'input':
+        return i18nString(UIStrings.elementRoleInput);
+      default:
+        return i18nString(UIStrings.elementRoleFallback);
+    }
+  }
+
+  #getSelectorPreview(): string {
+    if (!this.#step || !('selectors' in this.#step)) {
+      return '';
+    }
+
+    const ariaSelector = this.#step.selectors.flat().find(selector => selector.startsWith('aria/'));
+
+    if (!ariaSelector) {
+      return '';
+    }
+
+    const m = ariaSelector.match(/^aria\/(.+?)(\[role="(.+)"\])?$/);
+    if (!m) {
+      return '';
+    }
+
+    return `${this.#getElementRoleTitle(m[3])} "${m[1]}"`;
+  }
+
+  #getSectionPreview(): string {
+    if (!this.#section) {
+      return '';
+    }
+    return this.#section.url;
+  }
+
+  #stepEdited(event: StepEditedEvent): void {
+    const step = this.#step || this.#section?.causingStep;
+    if (!step) {
+      throw new Error('Expected step.');
+    }
+    this.dispatchEvent(new StepChanged(step, event.data));
+  }
+
+  #handleStepAction(event: Menus.Menu.MenuItemSelectedEvent): void {
+    switch (event.itemValue) {
+      case 'add-step-before': {
+        const stepOrSection = this.#step || this.#section;
+        if (!stepOrSection) {
+          throw new Error('Expected step or section.');
+        }
+        this.dispatchEvent(new AddStep(stepOrSection, AddStepPosition.BEFORE));
+        break;
+      }
+      case 'add-step-after': {
+        const stepOrSection = this.#step || this.#section;
+        if (!stepOrSection) {
+          throw new Error('Expected step or section.');
+        }
+        this.dispatchEvent(new AddStep(stepOrSection, AddStepPosition.AFTER));
+        break;
+      }
+      case 'remove-step': {
+        const causingStep = this.#section?.causingStep;
+        if (!this.#step && !causingStep) {
+          throw new Error('Expected step.');
+        }
+        this.dispatchEvent(
+            new RemoveStep(this.#step || (causingStep as Models.Schema.Step)),
+        );
+        break;
+      }
+      case 'add-breakpoint': {
+        if (!this.#step) {
+          throw new Error('Expected step');
+        }
+        this.dispatchEvent(new AddBreakpointEvent(this.#stepIndex));
+        break;
+      }
+      case 'remove-breakpoint': {
+        if (!this.#step) {
+          throw new Error('Expected step');
+        }
+        this.dispatchEvent(new RemoveBreakpointEvent(this.#stepIndex));
+        break;
+      }
+      default: {
+        const actionId = event.itemValue as string;
+        if (!actionId.startsWith(COPY_ACTION_PREFIX)) {
+          throw new Error('Unknown step action.');
+        }
+
+        const copyStep = this.#step || this.#section?.causingStep;
+        if (!copyStep) {
+          throw new Error('Step not found.');
+        }
+
+        const converterId = actionId.substring(COPY_ACTION_PREFIX.length);
+        if (this.#recorderSettings) {
+          this.#recorderSettings.preferredCopyFormat = converterId;
+        }
+
+        this.dispatchEvent(new CopyStepEvent(structuredClone(copyStep)));
+      }
+    }
+  }
+
+  #onToggleActionsMenu(event: Event): void {
+    event.stopPropagation();
+    event.preventDefault();
+    this.#actionsMenuExpanded = !this.#actionsMenuExpanded;
+    this.#render();
+  }
+
+  #onCloseActionsMenu(): void {
+    this.#actionsMenuExpanded = false;
+    this.#render();
+  }
+
+  #onBreakpointClick(): void {
+    if (this.#hasBreakpoint) {
+      this.dispatchEvent(new RemoveBreakpointEvent(this.#stepIndex));
+    } else {
+      this.dispatchEvent(new AddBreakpointEvent(this.#stepIndex));
+    }
+    this.#render();
+  }
+
+  #getActionsMenuButton(): Buttons.Button.Button {
+    if (!this.#actionsMenuButton) {
+      throw new Error('Missing actionsMenuButton');
+    }
+    return this.#actionsMenuButton;
+  }
+
+  #getActions = (): Array<Action> => {
+    const actions = [];
+
+    if (!this.#isPlaying) {
+      if (this.#step) {
+        actions.push({
+          id: 'add-step-before',
+          label: i18nString(UIStrings.addStepBefore),
+          group: 'stepManagement',
+          groupTitle: i18nString(UIStrings.stepManagement),
+        });
+      }
+
+      actions.push({
+        id: 'add-step-after',
+        label: i18nString(UIStrings.addStepAfter),
+        group: 'stepManagement',
+        groupTitle: i18nString(UIStrings.stepManagement),
+      });
+
+      if (this.#removable) {
+        actions.push({
+          id: 'remove-step',
+          group: 'stepManagement',
+          groupTitle: i18nString(UIStrings.stepManagement),
+          label: i18nString(UIStrings.removeStep),
+        });
+      }
+    }
+
+    if (this.#step && !this.#isRecording) {
+      if (this.#hasBreakpoint) {
+        actions.push({
+          id: 'remove-breakpoint',
+          label: i18nString(UIStrings.removeBreakpoint),
+          group: 'breakPointManagement',
+          groupTitle: i18nString(UIStrings.breakpoints),
+        });
+      } else {
+        actions.push({
+          id: 'add-breakpoint',
+          label: i18nString(UIStrings.addBreakpoint),
+          group: 'breakPointManagement',
+          groupTitle: i18nString(UIStrings.breakpoints),
+        });
+      }
+    }
+
+    if (this.#step) {
+      for (const converter of this.#builtInConverters || []) {
+        actions.push({
+          id: COPY_ACTION_PREFIX + converter.getId(),
+          label: converter.getFormatName(),
+          group: 'copy',
+          groupTitle: i18nString(UIStrings.copyAs),
+        });
+      }
+      for (const converter of this.#extensionConverters || []) {
+        actions.push({
+          id: COPY_ACTION_PREFIX + converter.getId(),
+          label: converter.getFormatName(),
+          group: 'copy',
+          groupTitle: i18nString(UIStrings.copyAs),
+        });
+      }
+    }
+
+    return actions;
+  };
+
+  #renderStepActions(): LitHtml.TemplateResult|null {
+    const actions = this.#getActions();
+    const groupsById = new Map<string, Action[]>();
+    for (const action of actions) {
+      const group = groupsById.get(action.group);
+      if (!group) {
+        groupsById.set(action.group, [action]);
+      } else {
+        group.push(action);
+      }
+    }
+    const groups = [];
+    for (const [group, actions] of groupsById) {
+      groups.push({
+        group,
+        groupTitle: actions[0].groupTitle,
+        actions,
+      });
+    }
+    // clang-format off
+    return LitHtml.html`
+      <${Buttons.Button.Button.litTagName}
+        class="step-actions"
+        title=${i18nString(UIStrings.openStepActions)}
+        aria-label=${i18nString(UIStrings.openStepActions)}
+        @click=${this.#onToggleActionsMenu}
+        @keydown=${(event: Event): void => {
+          event.stopPropagation();
+        }}
+        on-render=${ComponentHelpers.Directives.nodeRenderedCallback(node => {
+          this.#actionsMenuButton = node as Buttons.Button.Button;
+        })}
+        .data=${
+          {
+            variant: Buttons.Button.Variant.TOOLBAR,
+            iconUrl: actionsMenuIconUrl,
+            size: Buttons.Button.Size.SMALL,
+            title: i18nString(UIStrings.openStepActions),
+          } as Buttons.Button.ButtonData
+        }
+      ></${Buttons.Button.Button.litTagName}>
+      <${Menus.Menu.Menu.litTagName}
+        @menucloserequest=${this.#onCloseActionsMenu}
+        @menuitemselected=${this.#handleStepAction}
+        .origin=${this.#getActionsMenuButton.bind(this)}
+        .showSelectedItem=${false}
+        .showConnector=${false}
+        .open=${this.#actionsMenuExpanded}
+      >
+        ${LitHtml.Directives.repeat(
+          groups,
+          item => item.group,
+          item => {
+            return LitHtml.html`
+            <${Menus.Menu.MenuGroup.litTagName}
+              .name=${item.groupTitle}
+            >
+              ${LitHtml.Directives.repeat(
+                item.actions,
+                item => item.id,
+                item => {
+                  return LitHtml.html`<${Menus.Menu.MenuItem.litTagName}
+                      .value=${item.id}
+                    >
+                      ${item.label}
+                    </${Menus.Menu.MenuItem.litTagName}>
+                  `;
+                },
+              )}
+            </${Menus.Menu.MenuGroup.litTagName}>
+          `;
+          },
+        )}
+      </${Menus.Menu.Menu.litTagName}>
+    `;
+    // clang-format on
+  }
+
+  # MouseEvent): void => {
+    if (event.button !== 2) {
+      // 2 = secondary button = right click. We only show context menus if the
+      // user has right clicked.
+      return;
+    }
+
+    const menu = new UI.ContextMenu.ContextMenu(event);
+
+    const actions = this.#getActions();
+    const copyActions = actions.filter(
+        item => item.id.startsWith(COPY_ACTION_PREFIX),
+    );
+    const otherActions = actions.filter(
+        item => !item.id.startsWith(COPY_ACTION_PREFIX),
+    );
+    for (const item of otherActions) {
+      const section = menu.section(item.group);
+      section.appendItem(item.label, () => {
+        this.#handleStepAction(
+            new Menus.Menu.MenuItemSelectedEvent(item.id),
+        );
+      });
+    }
+
+    const preferredCopyAction = copyActions.find(
+        item => item.id === COPY_ACTION_PREFIX + this.#recorderSettings?.preferredCopyFormat,
+    );
+
+    if (preferredCopyAction) {
+      menu.section('copy').appendItem(preferredCopyAction.label, () => {
+        this.#handleStepAction(
+            new Menus.Menu.MenuItemSelectedEvent(preferredCopyAction.id),
+        );
+      });
+    }
+
+    if (copyActions.length) {
+      const copyAs = menu.section('copy').appendSubMenuItem(i18nString(UIStrings.copyAs));
+      for (const item of copyActions) {
+        if (item === preferredCopyAction) {
+          continue;
+        }
+        copyAs.section(item.group).appendItem(item.label, () => {
+          this.#handleStepAction(
+              new Menus.Menu.MenuItemSelectedEvent(item.id),
+          );
+        });
+      }
+    }
+
+    void menu.show();
+  };
+
+  #render(): void {
+    if (!this.#step && !this.#section) {
+      return;
+    }
+
+    const stepClasses = {
+      step: true,
+      expanded: this.#showDetails,
+      'is-success': this.#state === State.Success,
+      'is-current': this.#state === State.Current,
+      'is-outstanding': this.#state === State.Outstanding,
+      'is-error': this.#state === State.Error,
+      'is-stopped': this.#state === State.Stopped,
+      'is-start-of-group': this.#isStartOfGroup,
+      'is-first-section': this.#isFirstSection,
+      'has-breakpoint': this.#hasBreakpoint,
+    };
+    const isExpandable = Boolean(this.#step);
+    const mainTitle = this.#getStepTypeTitle();
+    const subtitle = this.#step ? this.#getSelectorPreview() : this.#getSectionPreview();
+
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+      <${TimelineSection.litTagName} .data=${
+        {
+          isFirstSection: this.#isFirstSection,
+          isLastSection: this.#isLastSection,
+          isStartOfGroup: this.#isStartOfGroup,
+          isEndOfGroup: this.#isEndOfGroup,
+          isSelected: this.#isSelected,
+        } as TimelineSectionData
+      } @contextmenu=${this.#onStepContextMenu} data-step-index=${
+        this.#stepIndex
+      } data-section-index=${
+        this.#sectionIndex
+      } class=${LitHtml.Directives.classMap(stepClasses)}>
+        <svg slot="icon" width="24" height="24" height="100%" class="icon">
+          <circle class="circle-icon"/>
+          <g class="error-icon">
+            <path d="M1.5 1.5L6.5 6.5" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            <path d="M1.5 6.5L6.5 1.5" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+          </g>
+          <path @click=${this.#onBreakpointClick.bind(
+            this,
+          )} class="breakpoint-icon" d="M2.5 5.5H17.7098L21.4241 12L17.7098 18.5H2.5V5.5Z"/>
+        </svg>
+        <div class="summary">
+          <div class="title-container ${isExpandable ? 'action' : ''}"
+            @click=${isExpandable && this.#toggleShowDetails.bind(this)}
+            @keydown=${
+              isExpandable && this.#onToggleShowDetailsKeydown.bind(this)
+            }
+            tabindex="0"
+            aria-role=${isExpandable ? 'button' : ''}
+            aria-label=${isExpandable ? 'Show details for step' : ''}
+          >
+            ${
+              isExpandable
+                ? LitHtml.html`<${IconButton.Icon.Icon.litTagName}
+              class="chevron"
+              .data=${
+                {
+                  iconPath: deployMenuArrow,
+                  color: 'var(--color-text-primary)',
+                } as IconButton.Icon.IconData
+              }>
+            </${IconButton.Icon.Icon.litTagName}>`
+                : ''
+            }
+            <div class="title">
+              <div class="main-title" title=${mainTitle}>${mainTitle}</div>
+              <div class="subtitle" title=${subtitle}>${subtitle}</div>
+            </div>
+          </div>
+          <div class="filler"></div>
+          ${this.#renderStepActions()}
+        </div>
+        <div class="details">
+          ${
+            this.#step &&
+            LitHtml.html`<devtools-recorder-step-editor
+            .step=${this.#step}
+            .disabled=${this.#isPlaying}
+            @stepedited=${this.#stepEdited}>
+          </devtools-recorder-step-editor>`
+          }
+          ${
+            this.#section?.causingStep &&
+            LitHtml.html`<devtools-recorder-step-editor
+            .step=${this.#section.causingStep}
+            .isTypeEditable=${false}
+            .disabled=${this.#isPlaying}
+            @stepedited=${this.#stepEdited}>
+          </devtools-recorder-step-editor>`
+          }
+        </div>
+        ${
+          this.#error &&
+          LitHtml.html`
+          <div class="error" role="alert">
+            ${this.#error.message}
+          </div>
+        `
+        }
+      </${TimelineSection.litTagName}>
+    `,
+      this.#shadow,
+      { host: this },
+    );
+    // clang-format on
+  }
+}
+
+ComponentHelpers.CustomElements.defineComponent('devtools-step-view', StepView);
diff --git a/front_end/panels/recorder/components/TimelineSection.ts b/front_end/panels/recorder/components/TimelineSection.ts
new file mode 100644
index 0000000..d50a9cb
--- /dev/null
+++ b/front_end/panels/recorder/components/TimelineSection.ts
@@ -0,0 +1,86 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as LitHtml from '../../../ui/lit-html/lit-html.js';
+
+import timelineSectionStyles from './timelineSection.css.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'devtools-timeline-section': TimelineSection;
+  }
+}
+
+export interface TimelineSectionData {
+  isFirstSection: boolean;
+  isLastSection: boolean;
+  isStartOfGroup: boolean;
+  isEndOfGroup: boolean;
+  isSelected: boolean;
+}
+
+export class TimelineSection extends HTMLElement {
+  static readonly litTagName = LitHtml.literal`devtools-timeline-section`;
+
+  #isEndOfGroup = false;
+  #isStartOfGroup = false;
+  #isFirstSection = false;
+  #isLastSection = false;
+  #isSelected = false;
+
+  constructor() {
+    super();
+
+    const shadowRoot = this.attachShadow({mode: 'open'});
+    shadowRoot.adoptedStyleSheets = [timelineSectionStyles];
+  }
+
+  set data(data: TimelineSectionData) {
+    this.#isFirstSection = data.isFirstSection;
+    this.#isLastSection = data.isLastSection;
+    this.#isEndOfGroup = data.isEndOfGroup;
+    this.#isStartOfGroup = data.isStartOfGroup;
+    this.#isSelected = data.isSelected;
+
+    this.#render();
+  }
+
+  connectedCallback(): void {
+    this.#render();
+  }
+
+  #render(): void {
+    const classes = {
+      'timeline-section': true,
+      'is-end-of-group': this.#isEndOfGroup,
+      'is-start-of-group': this.#isStartOfGroup,
+      'is-first-section': this.#isFirstSection,
+      'is-last-section': this.#isLastSection,
+      'is-selected': this.#isSelected,
+    };
+
+    // clang-format off
+    LitHtml.render(
+      LitHtml.html`
+      <div class=${LitHtml.Directives.classMap(classes)}>
+        <div class="overlay"></div>
+        <div class="icon"><slot name="icon"></slot></div>
+        <svg width="24" height="100%" class="bar">
+          <rect class="line" x="7" y="0" width="2" height="100%" />
+        </svg>
+        <slot></slot>
+      </div>
+    `,
+      this.shadowRoot as ShadowRoot,
+      { host: this },
+    );
+    // clang-format on
+  }
+}
+
+ComponentHelpers.CustomElements.defineComponent(
+    'devtools-timeline-section',
+    TimelineSection,
+);
diff --git a/front_end/panels/recorder/components/components.ts b/front_end/panels/recorder/components/components.ts
new file mode 100644
index 0000000..7eec492
--- /dev/null
+++ b/front_end/panels/recorder/components/components.ts
@@ -0,0 +1,31 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ControlButton from './ControlButton.js';
+import * as CreateRecordingView from './CreateRecordingView.js';
+import * as RecorderInput from './RecorderInput.js';
+import * as RecordingListView from './RecordingListView.js';
+import * as RecordingView from './RecordingView.js';
+import * as ReplayButton from './ReplayButton.js';
+import * as SelectButton from './SelectButton.js';
+import * as SplitView from './SplitView.js';
+import * as StartView from './StartView.js';
+import * as StepEditor from './StepEditor.js';
+import * as StepView from './StepView.js';
+import * as TimelineSection from './TimelineSection.js';
+
+export {
+  ControlButton,
+  CreateRecordingView,
+  RecorderInput,
+  RecordingListView,
+  RecordingView,
+  ReplayButton,
+  SelectButton,
+  SplitView,
+  StartView,
+  StepEditor,
+  StepView,
+  TimelineSection,
+};
diff --git a/front_end/panels/recorder/components/controlButton.css b/front_end/panels/recorder/components/controlButton.css
new file mode 100644
index 0000000..454c050
--- /dev/null
+++ b/front_end/panels/recorder/components/controlButton.css
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-size: inherit;
+}
+
+.control {
+  background: none;
+  border: none;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.control[disabled] {
+  filter: grayscale(100%);
+  cursor: auto;
+}
+
+.icon {
+  display: flex;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  background: var(--color-red);
+  margin-bottom: 8px;
+  position: relative;
+  transition: background 200ms;
+  justify-content: center;
+  align-content: center;
+  align-items: center;
+}
+
+.icon::before {
+  --override-white: #fff;
+
+  box-sizing: border-box;
+  content: "";
+  display: block;
+  width: 14px;
+  height: 14px;
+  border: 1px solid var(--override-white);
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  background-color: var(--override-white);
+}
+
+.icon.square::before {
+  border-radius: 0;
+}
+
+.icon.circle::before {
+  border-radius: 50%;
+}
+
+.icon:hover {
+  background: var(--color-accent-red);
+}
+
+.icon:active {
+  background: var(--color-red);
+}
+
+.control[disabled] .icon:hover {
+  background: var(--color-red);
+}
+
+.label {
+  font-size: 12px;
+  line-height: 16px;
+  text-align: center;
+  letter-spacing: 0.02em;
+  color: var(--color-text-primary);
+}
diff --git a/front_end/panels/recorder/components/createRecordingView.css b/front_end/panels/recorder/components/createRecordingView.css
new file mode 100644
index 0000000..15f9262
--- /dev/null
+++ b/front_end/panels/recorder/components/createRecordingView.css
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  margin: 0;
+  padding: 0;
+  outline: none;
+  box-sizing: border-box;
+  font-size: inherit;
+}
+
+.wrapper {
+  padding: 24px;
+  flex: 1;
+}
+
+h1 {
+  font-size: 18px;
+  line-height: 24px;
+  letter-spacing: 0.02em;
+  color: var(--color-text-primary);
+  margin: 0;
+  font-weight: normal;
+}
+
+.row-label {
+  font-weight: 500;
+  font-size: 11px;
+  line-height: 16px;
+  letter-spacing: 0.8px;
+  text-transform: uppercase;
+  color: var(--color-text-secondary);
+  margin-bottom: 8px;
+  display: inline-block;
+  margin-top: 32px;
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  border-top: 1px solid var(--color-details-hairline);
+  padding: 12px;
+  background: var(--color-background);
+}
+
+.controls {
+  display: flex;
+}
+
+.error {
+  margin: 16px 0 0;
+  padding: 8px;
+  background: var(--color-error-background);
+  color: var(--color-error-text);
+}
+
+.row-label .link {
+  position: relative;
+  top: 0.25em;
+}
+
+.row-label .link:focus-visible {
+  outline: var(--color-input-outline-active) auto 1px;
+}
+
+.header-wrapper {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+}
+
+.checkbox-label {
+  display: inline-flex;
+  align-items: center;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  gap: 4px;
+  line-height: 1.1;
+  padding: 4px;
+}
+
+.checkbox-container {
+  display: flex;
+  flex-flow: row wrap;
+  gap: 10px;
+}
+
+input[type="checkbox"]:focus-visible {
+  outline: var(--color-input-outline-active) auto 1px;
+}
diff --git a/front_end/panels/recorder/components/extensionView.css b/front_end/panels/recorder/components/extensionView.css
new file mode 100644
index 0000000..3a1dae2
--- /dev/null
+++ b/front_end/panels/recorder/components/extensionView.css
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  margin: 0;
+  padding: 0;
+  outline: none;
+  box-sizing: border-box;
+  font-size: inherit;
+}
+
+.extension-view {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+main {
+  flex: 1;
+}
+
+iframe {
+  border: none;
+  height: 100%;
+  width: 100%;
+}
+
+header {
+  display: flex;
+  padding: 3px 8px;
+  justify-content: space-between;
+  border-bottom: 1px solid var(--color-details-hairline);
+}
+
+header > div {
+  align-self: center;
+}
+
+.icon {
+  display: block;
+  width: 16px;
+  height: 16px;
+}
+
+.title {
+  display: flex;
+  flex-direction: row;
+  gap: 6px;
+  color: var(--color-text-secondary);
+  align-items: center;
+  font-weight: 500;
+}
diff --git a/front_end/panels/recorder/components/recorderInput.css b/front_end/panels/recorder/components/recorderInput.css
new file mode 100644
index 0000000..bfba86e
--- /dev/null
+++ b/front_end/panels/recorder/components/recorderInput.css
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  box-sizing: border-box;
+  font-size: inherit;
+  margin: 0;
+  padding: 0;
+}
+
+devtools-editable-content {
+  background: transparent;
+  border: none;
+  color: var(--color-text-primary);
+  cursor: text;
+  display: inline-block;
+  line-height: 18px;
+  min-height: 18px;
+  min-width: 0.5em;
+  outline: none;
+  overflow-wrap: anywhere;
+}
+
+devtools-editable-content:hover,
+devtools-editable-content:focus {
+  box-shadow: 0 0 0 1px var(--color-details-hairline);
+  border-radius: 2px;
+}
+
+devtools-editable-content[placeholder]:empty::before {
+  content: attr(placeholder);
+  color: var(--color-text-primary);
+  opacity: 50%;
+}
+
+devtools-editable-content[placeholder]:empty:focus::before {
+  content: "";
+}
+
+devtools-suggestion-box {
+  position: absolute;
+  visibility: hidden;
+  display: block;
+}
+
+devtools-editable-content:focus ~ devtools-suggestion-box {
+  visibility: visible;
+}
+
+.suggestions {
+  background-color: var(--color-background);
+  box-shadow: var(--drop-shadow);
+  min-height: 1em;
+  min-width: 150px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  position: relative;
+  z-index: 100;
+}
+
+.suggestions > li {
+  padding: 1px;
+  border: 1px solid transparent;
+  white-space: nowrap;
+  font-family: var(--source-code-font-family);
+  font-size: var(--source-code-font-size);
+  color: var(--color-text-primary);
+}
+
+.suggestions > li:hover {
+  background-color: var(--item-hover-color);
+}
+
+.suggestions > li.selected {
+  background-color: var(--color-primary-old);
+  color: var(--color-background);
+}
diff --git a/front_end/panels/recorder/components/recordingListView.css b/front_end/panels/recorder/components/recordingListView.css
new file mode 100644
index 0000000..5311b2c
--- /dev/null
+++ b/front_end/panels/recorder/components/recordingListView.css
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-size: inherit;
+}
+
+*:focus,
+*:focus-visible {
+  outline: none;
+}
+
+.wrapper {
+  padding: 24px;
+}
+
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+h1 {
+  font-size: 16px;
+  line-height: 19px;
+  color: var(--color-text-primary);
+  font-weight: normal;
+}
+
+devtools-icon {
+  width: 24px;
+  height: 24px;
+}
+
+.icon {
+  width: 24px;
+  height: 24px;
+}
+
+.table {
+  margin-top: 35px;
+}
+
+.title {
+  font-size: 13px;
+  color: var(--color-text-primary);
+  margin-left: 10px;
+  flex: 1;
+  overflow-x: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.row {
+  display: flex;
+  align-items: center;
+  padding-right: 5px;
+  height: 28px;
+  border-bottom: 1px solid var(--color-details-hairline);
+}
+
+.row:focus-within,
+.row:hover {
+  background-color: var(--color-background-elevation-1);
+}
+
+.row:last-child {
+  border-bottom: none;
+}
+
+.actions {
+  display: flex;
+  align-items: center;
+}
+
+.actions button {
+  border: none;
+  background-color: transparent;
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+
+  --override-background-color: rgba(26 115 232 / 15%);
+}
+
+.actions button:hover,
+.actions button:focus-visible {
+  background-color: var(--override-background-color);
+}
+
+.actions button devtools-icon {
+  width: 24px;
+  height: 24px;
+}
+
+.actions button:hover devtools-icon,
+.actions button:focus-visible devtools-icon {
+  --icon-color: var(--color-primary-old);
+}
+
+.actions .divider {
+  width: 1px;
+  height: 17px;
+  background-color: var(--color-details-hairline);
+  margin: 0 6px;
+}
diff --git a/front_end/panels/recorder/components/recordingView.css b/front_end/panels/recorder/components/recordingView.css
new file mode 100644
index 0000000..310848e
--- /dev/null
+++ b/front_end/panels/recorder/components/recordingView.css
@@ -0,0 +1,379 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  padding: 0;
+  margin: 0;
+  box-sizing: border-box;
+  font-size: inherit;
+}
+
+.wrapper {
+  display: flex;
+  flex-direction: row;
+  flex: 1;
+  height: 100%;
+}
+
+.main {
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+}
+
+.sections {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden auto;
+  background-color: var(--color-background);
+  z-index: 0;
+  position: relative;
+  container: sections / inline-size; /* stylelint-disable-line property-no-unknown */
+}
+
+.section {
+  display: flex;
+  padding: 0 16px;
+  gap: 8px;
+  position: relative;
+}
+
+.section::after {
+  content: "";
+  border-bottom: 1px solid var(--color-details-hairline);
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: -1;
+}
+
+.section:last-child {
+  /* Make sure there is enough space for the context menu. */
+  margin-bottom: 70px;
+}
+
+.section:last-child::after {
+  content: none;
+}
+
+.screenshot-wrapper {
+  flex: 0 0 80px;
+  padding-top: 32px;
+  z-index: 2; /* We want this to be on top of `.step-overlay` */
+}
+
+/* stylelint-disable-next-line at-rule-no-unknown */
+@container sections (max-width: 400px) {
+  .screenshot-wrapper {
+    display: none;
+  }
+}
+
+.screenshot {
+  object-fit: cover;
+  object-position: top center;
+  max-width: 100%;
+  width: 200px;
+  height: auto;
+  border: 1px solid var(--color-details-hairline);
+  border-radius: 1px;
+}
+
+.content {
+  flex: 1;
+  min-width: 0;
+}
+
+.steps {
+  flex: 1;
+  position: relative;
+  align-self: flex-start;
+  overflow: visible;
+}
+
+.step {
+  position: relative;
+  padding-left: 40px;
+  margin: 16px 0;
+}
+
+.step .action {
+  font-size: 13px;
+  line-height: 16px;
+  letter-spacing: 0.03em;
+}
+
+.title {
+  font-size: 13px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  color: var(--color-text-primary);
+  letter-spacing: 0.03em;
+  font-weight: bold;
+  margin: 0;
+}
+
+.title .fallback {
+  color: var(--color-text-secondary);
+}
+
+.recording {
+  color: var(--color-primary-old);
+  font-style: italic;
+  margin-top: 8px;
+  margin-bottom: 0;
+}
+
+.add-assertion-button {
+  margin-top: 8px;
+}
+
+.details {
+  max-width: 240px;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+}
+
+.url {
+  font-size: 12px;
+  line-height: 16px;
+  letter-spacing: 0.03em;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  color: var(--color-text-secondary);
+  max-width: 100%;
+  margin-bottom: 16px;
+}
+
+.header {
+  align-items: center;
+  border-bottom: 1px solid var(--color-details-hairline);
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  justify-content: space-between;
+  padding: 16px;
+}
+
+.header-title-wrapper {
+  max-width: 100%;
+}
+
+.header-title {
+  align-items: center;
+  display: flex;
+  flex: 1;
+  max-width: 100%;
+}
+
+.header-title::before {
+  content: "";
+  min-width: 12px;
+  height: 12px;
+  display: inline-block;
+  background: var(--color-primary-old);
+  border-radius: 50%;
+  margin-right: 7px;
+}
+
+#title-input {
+  box-sizing: content-box;
+  font-family: inherit;
+  font-size: 18px;
+  line-height: 22px;
+  letter-spacing: 0.02em;
+  padding: 1px 4px;
+  border: 1px solid transparent;
+  border-radius: 1px;
+}
+
+#title-input:hover {
+  border-color: var(--input-outline);
+}
+
+#title-input.has-error {
+  border-color: var(--color-input-outline-error);
+}
+
+#title-input-label {
+  font-family: inherit;
+  font-size: 18px;
+  line-height: 22px;
+  letter-spacing: 0.02em;
+  position: absolute;
+  white-space: pre;
+  visibility: hidden;
+}
+
+.title-input-error-text {
+  margin-top: 4px;
+  margin-left: 19px;
+  color: var(--color-error-text);
+}
+
+.title-button-bar {
+  padding-left: 2px;
+  display: flex;
+}
+
+#title-input:focus + .title-button-bar {
+  display: none;
+}
+
+.settings-row {
+  padding: 16px 28px;
+  border-bottom: 1px solid var(--color-details-hairline);
+  display: flex;
+  flex-flow: row wrap;
+  justify-content: space-between;
+}
+
+.settings-title {
+  font-size: 14px;
+  line-height: 24px;
+  letter-spacing: 0.03em;
+  color: var(--color-text-primary);
+  display: flex;
+  align-items: center;
+  align-content: center;
+  gap: 5px;
+}
+
+.settings {
+  margin-top: 4px;
+  display: flex;
+  font-size: 12px;
+  line-height: 20px;
+  letter-spacing: 0.03em;
+  color: var(--color-text-secondary);
+}
+
+.settings.expanded {
+  gap: 10px;
+}
+
+.settings .separator {
+  width: 1px;
+  height: 20px;
+  background-color: var(--color-details-hairline);
+  margin: 0 5px;
+}
+
+.actions {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+
+.is-recording .header-title::before {
+  background: var(--color-red);
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  border-top: 1px solid var(--color-details-hairline);
+  padding: 12px;
+  background: var(--color-background);
+  z-index: 1;
+}
+
+.controls {
+  align-items: center;
+  display: flex;
+  justify-content: center;
+  position: relative;
+  width: 100%;
+}
+
+.chevron {
+  width: 10px;
+  height: 13px;
+  top: 2px;
+  left: 24px;
+  transform: rotate(-90deg);
+}
+
+.expanded .chevron {
+  transform: rotate(0);
+}
+
+.editable-setting {
+  display: flex;
+  flex-direction: row;
+  gap: 12px;
+  align-items: center;
+}
+
+.editable-setting devtools-select-menu {
+  height: 32px;
+}
+
+.editable-setting .devtools-text-input {
+  width: fit-content;
+}
+
+.wrapping-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.text-editor {
+  height: 100%;
+  overflow: auto;
+}
+
+.section-toolbar {
+  display: flex;
+  padding: 3px;
+  justify-content: space-between;
+  gap: 3px;
+}
+
+.section-toolbar > devtools-select-menu {
+  min-width: 50px;
+}
+
+.sections .section-toolbar {
+  justify-content: flex-end;
+}
+
+.section-toolbar > * {
+  height: 24px;
+}
+
+devtools-split-view {
+  flex: 1 1 0%;
+  min-height: 0;
+}
+
+[slot="sidebar"] {
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
+  height: 100%;
+  width: 100%;
+}
+
+[slot="sidebar"] .section-toolbar {
+  border-bottom: 1px solid var(--color-details-hairline);
+}
+
+.show-code {
+  margin-right: 14px;
+  margin-top: 8px;
+}
+
+devtools-recorder-extension-view {
+  flex: 1;
+}
diff --git a/front_end/panels/recorder/components/selectButton.css b/front_end/panels/recorder/components/selectButton.css
new file mode 100644
index 0000000..714433e
--- /dev/null
+++ b/front_end/panels/recorder/components/selectButton.css
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+.select-button {
+  display: flex;
+
+  --override-button-no-right-border-radius: 1; /* True */
+}
+
+.select-button devtools-button {
+  position: relative; /* Needed for outline to appear on top of the next element */
+}
+
+.select-menu-item-content-with-icon {
+  display: flex;
+  align-items: center;
+}
+
+.select-menu-item-content-with-icon::before {
+  content: "";
+  position: relative;
+  left: 0;
+  top: 0;
+  background-color: var(--color-text-primary);
+  display: inline-block;
+  mask-repeat: no-repeat;
+  -webkit-mask-repeat: no-repeat;
+  mask-position: center;
+  -webkit-mask-position: center;
+  width: 24px;
+  height: 24px;
+  margin-right: 4px;
+  mask-image: var(--item-mask-icon);
+  -webkit-mask-image: var(--item-mask-icon);
+}
+
+devtools-select-menu {
+  height: var(--override-select-menu-height, 24px);
+  border-radius: 0 4px 4px 0;
+  box-sizing: border-box;
+
+  --override-select-menu-show-button-outline: var(--color-button-outline-focus);
+  --override-select-menu-label-with-arrow-padding: 0;
+  --override-select-menu-border: none;
+  --override-select-menu-show-button-padding: 0 6px 0 0;
+}
+
+devtools-select-menu.primary {
+  border: none;
+  border-left: 1px solid var(--override-icon-and-text-color);
+
+  --override-icon-and-text-color: var(--color-background);
+  --override-select-menu-arrow-color: var(--override-icon-and-text-color);
+  --override-divider-color: var(--override-icon-and-text-color);
+  --override-select-menu-background-color: var(--color-primary-old);
+  --override-select-menu-active-background-color:
+    var(
+      --override-select-menu-background-color
+    );
+}
+
+devtools-select-menu.primary:hover {
+  --override-select-menu-background-color:
+    var(
+      --color-button-primary-background-hovering
+    );
+}
+
+devtools-select-menu[disabled].primary,
+devtools-select-menu[disabled].primary:hover {
+  --override-icon-and-text-color: var(--color-text-disabled);
+  --override-select-menu-background-color: var(--color-background-elevation-1);
+}
+
+devtools-select-menu.secondary {
+  border: 1px solid var(--color-details-hairline);
+  border-left: none;
+
+  --override-icon-and-text-color: var(--color-primary-old);
+  --override-select-menu-arrow-color: var(--override-icon-and-text-color);
+  --override-divider-color: var(--color-details-hairline);
+  --override-select-menu-background-color: var(--color-background);
+  --override-select-menu-active-background-color:
+    var(
+      --override-select-menu-background-color
+    );
+}
+
+devtools-select-menu.secondary:hover {
+  --override-select-menu-background-color:
+    var(
+      --color-button-secondary-background-hovering
+    );
+}
+
+devtools-select-menu[disabled].secondary,
+devtools-select-menu[disabled].secondary:hover {
+  border: 1px solid var(--color-background-elevation-1);
+  border-left: none;
+
+  --override-icon-and-text-color: var(--color-text-disabled);
+  --override-select-menu-background-color: var(--color-background);
+}
diff --git a/front_end/panels/recorder/components/startView.css b/front_end/panels/recorder/components/startView.css
new file mode 100644
index 0000000..3630b2c
--- /dev/null
+++ b/front_end/panels/recorder/components/startView.css
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-weight: normal;
+  font-size: inherit;
+}
+
+:host {
+  flex: 1;
+  display: block;
+  overflow: auto;
+}
+
+.wrapper {
+  padding: 24px;
+  background-color: var(--color-background);
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.fit-content {
+  width: fit-content;
+}
+
+.align-right {
+  width: auto;
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-end;
+}
diff --git a/front_end/panels/recorder/components/stepEditor.css b/front_end/panels/recorder/components/stepEditor.css
new file mode 100644
index 0000000..18bff9b
--- /dev/null
+++ b/front_end/panels/recorder/components/stepEditor.css
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  box-sizing: border-box;
+  padding: 0;
+  margin: 0;
+  font-size: inherit;
+}
+
+:host {
+  display: block;
+}
+
+.row {
+  display: flex;
+  flex-direction: row;
+  color: var(--color-syntax-1);
+  font-family: var(--monospace-font-family);
+  font-size: var(--monospace-font-size);
+  align-items: center;
+  line-height: 18px;
+  margin-top: 3px;
+}
+
+.row devtools-button {
+  line-height: 1;
+  margin-left: 0.5em;
+}
+
+.separator {
+  margin-right: 0.5em;
+  color: var(--color-text-primary);
+}
+
+.padded {
+  margin-left: 2em;
+}
+
+.padded.double {
+  margin-left: 4em;
+}
+
+.selector-picker {
+  width: 18px;
+  height: 18px;
+}
+
+.inline-button {
+  width: 18px;
+  height: 18px;
+  opacity: 0%;
+  visibility: hidden;
+  transition: opacity 200ms;
+}
+
+.row:focus-within .inline-button,
+.row:hover .inline-button {
+  opacity: 100%;
+  visibility: visible;
+}
+
+.wrapped.row {
+  flex-wrap: wrap;
+}
+
+.gap.row {
+  gap: 5px;
+}
+
+.gap.row devtools-button {
+  margin-left: 0;
+}
+
+.regular-font {
+  font-family: inherit;
+  font-size: inherit;
+}
+
+.no-margin {
+  margin: 0;
+}
+
+.row-buttons {
+  margin-top: 3px;
+}
+
+.error {
+  margin: 3px 0 6px;
+  padding: 8px 12px;
+  background: var(--color-error-background);
+  color: var(--color-error-text);
+}
diff --git a/front_end/panels/recorder/components/stepView.css b/front_end/panels/recorder/components/stepView.css
new file mode 100644
index 0000000..a4a1bf1
--- /dev/null
+++ b/front_end/panels/recorder/components/stepView.css
@@ -0,0 +1,252 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-size: inherit;
+}
+
+.title-container {
+  /* 18px for 3 dot menu icon */
+  max-width: calc(100% - 18px);
+  font-size: 13px;
+  line-height: 16px;
+  letter-spacing: 0.03em;
+  display: flex;
+  flex-direction: row;
+  gap: 3px;
+  outline-offset: 3px;
+}
+
+.action {
+  display: flex;
+  align-items: flex-start;
+}
+
+.title {
+  flex: 1;
+  min-width: 0;
+}
+
+.is-start-of-group .title {
+  font-weight: bold;
+}
+
+.error-icon {
+  display: none;
+}
+
+.breakpoint-icon {
+  visibility: hidden;
+  cursor: pointer;
+  opacity: 0%;
+  fill: var(--color-primary-old);
+  stroke: #1a73e8; /* stylelint-disable-line plugin/use_theme_colors */
+  transform: translate(-1.92px, -3px);
+}
+
+.circle-icon {
+  fill: var(--color-primary-old);
+  stroke: var(--color-background);
+  stroke-width: 4px;
+  r: 5px;
+  cx: 8px;
+  cy: 8px;
+}
+
+.is-start-of-group .circle-icon {
+  r: 7px;
+  fill: var(--color-background);
+  stroke: var(--color-primary-old);
+  stroke-width: 2px;
+}
+
+.step.is-success .circle-icon {
+  fill: var(--color-primary-old);
+  stroke: var(--color-primary-old);
+}
+
+.step.is-current .circle-icon {
+  stroke-dasharray: 24 10;
+  animation: rotate 1s linear infinite;
+  fill: var(--color-background);
+  stroke: var(--color-primary-old);
+  stroke-width: 2px;
+}
+
+.error {
+  margin: 16px 0 0;
+  padding: 8px;
+  background: var(--color-error-background);
+  color: var(--color-error-text);
+  position: relative;
+}
+
+@keyframes rotate {
+  0% {
+    transform: translate(8px, 8px) rotate(0) translate(-8px, -8px);
+  }
+
+  100% {
+    transform: translate(8px, 8px) rotate(360deg) translate(-8px, -8px);
+  }
+}
+
+.step.is-error .circle-icon {
+  fill: var(--color-error-text);
+  stroke: var(--color-error-text);
+}
+
+.step.is-error .error-icon {
+  display: block;
+  transform: translate(4px, 4px);
+}
+
+:host-context(.was-successful) .circle-icon {
+  animation: flash-circle 2s;
+}
+
+:host-context(.was-successful) .breakpoint-icon {
+  animation: flash-breakpoint-icon 2s;
+}
+
+@keyframes flash-circle {
+  25% {
+    fill: var(--override-color-recording-successful-text);
+    stroke: var(--override-color-recording-successful-text);
+  }
+
+  75% {
+    fill: var(--override-color-recording-successful-text);
+    stroke: var(--override-color-recording-successful-text);
+  }
+}
+
+@keyframes flash-breakpoint-icon {
+  25% {
+    fill: var(--override-color-recording-successful-text);
+    stroke: var(--override-color-recording-successful-text);
+  }
+
+  75% {
+    fill: var(--override-color-recording-successful-text);
+    stroke: var(--override-color-recording-successful-text);
+  }
+}
+
+.chevron {
+  width: 10px;
+  height: 13px;
+  transition: 200ms;
+  position: absolute;
+  top: 18px;
+  left: 24px;
+  transform: rotate(-90deg);
+}
+
+.expanded .chevron {
+  transform: rotate(0deg);
+}
+
+.is-start-of-group .chevron {
+  top: 34px;
+}
+
+.details {
+  display: none;
+  margin-top: 8px;
+  position: relative;
+}
+
+.expanded .details {
+  display: block;
+}
+
+.step-details {
+  overflow: auto;
+}
+
+devtools-recorder-step-editor {
+  border: 1px solid var(--color-details-hairline);
+  padding: 3px 6px 6px;
+  margin-left: -6px;
+  border-radius: 3px;
+}
+
+devtools-recorder-step-editor:hover {
+  border: 1px solid var(--color-primary-old);
+}
+
+.summary {
+  display: flex;
+  flex-flow: row nowrap;
+}
+
+.filler {
+  flex-grow: 1;
+}
+
+.subtitle {
+  font-weight: normal;
+  color: var(--color-text-secondary);
+  word-break: break-all;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.main-title {
+  word-break: break-all;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.step-actions {
+  width: 18px;
+  height: 18px;
+  border: none;
+  border-radius: 0;
+
+  --override-select-menu-show-button-border-radius: 0;
+  --override-select-menu-show-button-outline: none;
+  --override-select-menu-show-button-padding: 0;
+}
+
+.step-actions:hover,
+.step-actions:focus-within {
+  background-color: var(--color-button-secondary-background-hovering);
+}
+
+.step-actions:active {
+  background-color: var(--color-background-highlight);
+}
+
+.step.has-breakpoint .circle-icon {
+  visibility: hidden;
+}
+
+.step:not(.is-start-of-group).has-breakpoint .breakpoint-icon {
+  visibility: visible;
+  opacity: 100%;
+}
+
+.step:not(.is-start-of-group):not(.has-breakpoint) .icon:hover .circle-icon {
+  transition: opacity 0.2s;
+  opacity: 0%;
+}
+
+.step:not(.is-start-of-group):not(.has-breakpoint) .icon:hover .error-icon {
+  visibility: hidden;
+}
+
+.step:not(.is-start-of-group):not(.has-breakpoint) .icon:hover .breakpoint-icon {
+  transition: opacity 0.2s;
+  visibility: visible;
+  opacity: 50%;
+}
diff --git a/front_end/panels/recorder/components/timelineSection.css b/front_end/panels/recorder/components/timelineSection.css
new file mode 100644
index 0000000..eb02fbb
--- /dev/null
+++ b/front_end/panels/recorder/components/timelineSection.css
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-size: inherit;
+}
+
+.timeline-section {
+  position: relative;
+  padding: 16px 0 16px 40px;
+  margin-left: 8px;
+
+  --override-color-recording-successful-text: #36a854;
+  --override-color-recording-successful-background: #e6f4ea;
+}
+
+.overlay {
+  position: absolute;
+  width: 100vw;
+  height: 100%;
+  /* Offset of 32px for spacing and 80px for screenshot */
+  left: calc(-32px - 80px);
+  top: 0;
+  z-index: -1;
+  pointer-events: none;
+}
+
+/* stylelint-disable-next-line at-rule-no-unknown */
+@container (max-width: 400px) {
+  .overlay {
+    /* Offset of 32px for spacing */
+    left: -32px;
+  }
+}
+
+:hover .overlay {
+  background: var(--color-background-elevation-1);
+}
+
+.is-selected .overlay {
+  background: var(--color-button-secondary-background-hovering);
+}
+
+:host-context(.is-stopped) .overlay {
+  background: var(--color-execution-line-background);
+  outline: 1px solid var(--color-execution-line-outline);
+  z-index: 4;
+}
+
+.is-start-of-group {
+  padding-top: 28px;
+}
+
+.is-end-of-group {
+  padding-bottom: 24px;
+}
+
+.icon {
+  position: absolute;
+  left: 4px;
+  transform: translateX(-50%);
+  z-index: 2;
+}
+
+.bar {
+  position: absolute;
+  left: 4px;
+  display: block;
+  transform: translateX(-50%);
+  top: 18px;
+  height: calc(100% + 8px);
+  z-index: 1; /* We want this to be below of `.overlay` for stopped case */
+}
+
+.bar .background {
+  fill: var(--color-background-elevation-1);
+}
+
+.bar .line {
+  fill: var(--color-primary-old);
+}
+
+.is-first-section .bar {
+  top: 32px;
+  height: calc(100% - 8px);
+  display: none;
+}
+
+.is-first-section:not(.is-last-section) .bar {
+  display: block;
+}
+
+.is-last-section .bar .line {
+  display: none;
+}
+
+.is-last-section .bar .background {
+  display: none;
+}
+
+:host-context(.is-error) .bar .line {
+  fill: var(--color-error-text);
+}
+
+:host-context(.is-error) .bar .background {
+  fill: var(--color-error-background);
+}
+
+:host-context(.was-successful) .bar .background {
+  animation: flash-background 2s;
+}
+
+:host-context(.was-successful) .bar .line {
+  animation: flash-line 2s;
+}
+
+@keyframes flash-background {
+  25% {
+    fill: var(--override-color-recording-successful-background);
+  }
+
+  75% {
+    fill: var(--override-color-recording-successful-background);
+  }
+}
+
+@keyframes flash-line {
+  25% {
+    fill: var(--override-color-recording-successful-text);
+  }
+
+  75% {
+    fill: var(--override-color-recording-successful-text);
+  }
+}
diff --git a/front_end/panels/recorder/components/util.ts b/front_end/panels/recorder/components/util.ts
new file mode 100644
index 0000000..8b2dd0e
--- /dev/null
+++ b/front_end/panels/recorder/components/util.ts
@@ -0,0 +1,116 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export const mod = (a: number, n: number): number => {
+  return ((a % n) + n) % n;
+};
+
+export function assert<T>(
+    predicate: T,
+    message = 'Assertion failed!',
+    ): asserts predicate {
+  if (!predicate) {
+    throw new Error(message);
+  }
+}
+
+export type Keys<T> = T extends T ? keyof T : never;
+
+export type RequiredKeys<T> = {
+  [K in keyof T] -?: {} extends Pick<T, K>? never : K;
+}[keyof T];
+
+export type OptionalKeys<T> = {
+  [K in keyof T] -?: {} extends Pick<T, K>? K : never;
+}[keyof T];
+
+export type DeepImmutable<T> = {
+  readonly[K in keyof T]: DeepImmutable<T[K]>;
+};
+
+export type DeepMutable<T> = {
+  -readonly[K in keyof T]: DeepMutable<T[K]>;
+};
+
+export type DeepPartial<T> = {
+  [K in keyof T]?: DeepPartial<Exclude<T[K], undefined>>;
+};
+
+export type Mutable<T> = {
+  -readonly[K in keyof T]: T[K];
+};
+
+export const deepFreeze = <T extends object>(object: T): DeepImmutable<T> => {
+  for (const name of Reflect.ownKeys(object)) {
+    const value = object[name as keyof T];
+    if ((value && typeof value === 'object') || typeof value === 'function') {
+      deepFreeze(value);
+    }
+  }
+  return Object.freeze(object);
+};
+
+export class InsertAssignment<T> {
+  value: T;
+  constructor(value: T) {
+    this.value = value;
+  }
+}
+
+export class ArrayAssignments<T> {
+  value: {[n: number]: T};
+  constructor(value: {[n: number]: T}) {
+    this.value = value;
+  }
+}
+
+export type Assignments<T> = T extends Readonly<Array<infer R>>?
+    R[]|ArrayAssignments<Assignments<R>|InsertAssignment<R>>:
+    {[K in keyof T]: Assignments<T[K]>};
+
+export const immutableDeepAssign = <T>(
+    object: DeepImmutable<T>,
+    assignments: DeepImmutable<DeepPartial<Assignments<T>>>,
+    ): DeepImmutable<T> => {
+  if (assignments instanceof ArrayAssignments) {
+    assert(Array.isArray(object), `Expected an array. Got ${typeof object}.`);
+    const updatedObject = [...object] as Mutable<typeof object>;
+    const keys = Object.keys(assignments.value)
+                     .sort(
+                         (a, b) => Number(b) - Number(a),
+                         ) as (keyof typeof updatedObject)[];
+    for (const key of keys) {
+      const update = assignments.value[Number(key)];
+      if (update === undefined) {
+        updatedObject.splice(Number(key), 1);
+      } else if (update instanceof InsertAssignment) {
+        updatedObject.splice(Number(key), 0, update.value);
+      } else {
+        updatedObject[Number(key)] = immutableDeepAssign(
+            updatedObject[key],
+            update,
+        );
+      }
+    }
+    return Object.freeze(updatedObject);
+  }
+  if (typeof assignments === 'object' && !Array.isArray(assignments)) {
+    assert(!Array.isArray(object), 'Expected an object. Got an array.');
+    const updatedObject = {...object} as Mutable<typeof object>;
+    const keys = Object.keys(assignments) as (keyof typeof assignments&keyof typeof updatedObject)[];
+    for (const key of keys) {
+      const update = assignments[key];
+      if (update === undefined) {
+        delete updatedObject[key];
+      } else {
+        updatedObject[key] = immutableDeepAssign(
+            updatedObject[key],
+            update as typeof updatedObject[typeof key],
+        );
+      }
+    }
+    return Object.freeze(updatedObject);
+  }
+  return assignments as DeepImmutable<T>;
+};
diff --git a/front_end/panels/recorder/controllers/BUILD.gn b/front_end/panels/recorder/controllers/BUILD.gn
new file mode 100644
index 0000000..99a1494
--- /dev/null
+++ b/front_end/panels/recorder/controllers/BUILD.gn
@@ -0,0 +1,33 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_module.gni")
+
+devtools_module("controllers") {
+  sources = [ "SelectorPicker.ts" ]
+
+  deps = [
+    "../../../core/common:bundle",
+    "../../../core/platform:bundle",
+    "../../../core/sdk:bundle",
+    "../../../generated:protocol",
+    "../models:bundle",
+    "../util:bundle",
+  ]
+
+  public_deps = [ "../injected:bundled_library" ]
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "controllers.ts"
+
+  deps = [ ":controllers" ]
+
+  visibility = [
+    "../*",
+    "../components/*",
+  ]
+}
diff --git a/front_end/panels/recorder/controllers/SelectorPicker.ts b/front_end/panels/recorder/controllers/SelectorPicker.ts
new file mode 100644
index 0000000..2fcd455
--- /dev/null
+++ b/front_end/panels/recorder/controllers/SelectorPicker.ts
@@ -0,0 +1,217 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../../core/common/common.js';
+import * as Platform from '../../../core/platform/platform.js';
+import * as SDK from '../../../core/sdk/sdk.js';
+// eslint-disable-next-line rulesdir/es_modules_import
+import type * as Protocol from '../../../generated/protocol.js';
+import type * as LitHtml from '../../../ui/lit-html/lit-html.js';
+import * as Models from '../models/models.js';
+
+import * as Util from '../util/util.js';
+
+const BINDING_NAME = 'captureSelectors';
+
+export class SelectorPickedEvent extends Event {
+  static readonly eventName = 'selectorpicked';
+  data: Models.Schema.StepWithSelectors&Pick<Models.Schema.ClickAttributes, 'offsetX'|'offsetY'>;
+
+  constructor(
+      data: Models.Schema.StepWithSelectors&Pick<Models.Schema.ClickAttributes, 'offsetX'|'offsetY'>,
+  ) {
+    super(SelectorPickedEvent.eventName, {bubbles: true, composed: true});
+    this.data = data;
+  }
+}
+
+export class RequestSelectorAttributeEvent extends Event {
+  static readonly eventName = 'requestselectorattribute';
+  send: (attribute?: string) => void;
+
+  constructor(send: (attribute?: string) => void) {
+    super(RequestSelectorAttributeEvent.eventName, {
+      bubbles: true,
+      composed: true,
+    });
+    this.send = send;
+  }
+}
+
+export class SelectorPicker implements SDK.TargetManager.Observer {
+  static get #targetManager(): SDK.TargetManager.TargetManager {
+    return SDK.TargetManager.TargetManager.instance();
+  }
+
+  readonly #element: LitHtml.LitElement;
+
+  #selectorAttribute?: string;
+
+  readonly #activeMutex = new Common.Mutex.Mutex();
+  active = false;
+
+  constructor(element: LitHtml.LitElement) {
+    this.#element = element;
+  }
+
+  start = (): Promise<void> => {
+    return this.#activeMutex.run(async () => {
+      if (this.active) {
+        return;
+      }
+      this.active = true;
+
+      this.#selectorAttribute = await new Promise<string|undefined>(
+          (resolve, reject) => {
+            const timeout = setTimeout(reject, 1000);
+            this.#element.dispatchEvent(
+                new RequestSelectorAttributeEvent(attribute => {
+                  clearTimeout(timeout);
+                  resolve(attribute);
+                }),
+            );
+          },
+      );
+
+      SelectorPicker.#targetManager.observeTargets(this);
+
+      this.#element.requestUpdate();
+    });
+  };
+
+  stop = (): Promise<void> => {
+    return this.#activeMutex.run(async () => {
+      if (!this.active) {
+        return;
+      }
+      this.active = false;
+
+      SelectorPicker.#targetManager.unobserveTargets(this);
+      SelectorPicker.#targetManager.targets().map(this.targetRemoved.bind(this));
+
+      this.#selectorAttribute = undefined;
+
+      this.#element.requestUpdate();
+    });
+  };
+
+  toggle = (): Promise<void> => {
+    if (!this.active) {
+      return this.start();
+    }
+    return this.stop();
+  };
+
+  readonly #targetMutexes = new Map<SDK.Target.Target, Common.Mutex.Mutex>();
+  targetAdded(target: SDK.Target.Target): void {
+    if (target.type() !== SDK.Target.Type.Frame) {
+      return;
+    }
+    let mutex = this.#targetMutexes.get(target);
+    if (!mutex) {
+      mutex = new Common.Mutex.Mutex();
+      this.#targetMutexes.set(target, mutex);
+    }
+    void mutex.run(async () => {
+      await this.#addBindings(target);
+      await this.#injectApplicationScript(target);
+    });
+  }
+  targetRemoved(target: SDK.Target.Target): void {
+    const mutex = this.#targetMutexes.get(target);
+    if (!mutex) {
+      return;
+    }
+    void mutex.run(async () => {
+      try {
+        await this.#injectCleanupScript(target);
+        await this.#removeBindings(target);
+      } catch {
+      }
+    });
+  }
+
+  #handleBindingCalledEvent = (
+      event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>,
+      ): void => {
+    if (event.data.name !== BINDING_NAME) {
+      return;
+    }
+    const contextId = event.data.executionContextId;
+    const frames = SDK.TargetManager.TargetManager.instance().targets();
+    const contextTarget = Models.SDKUtils.findTargetByExecutionContext(
+        frames,
+        contextId,
+    );
+    const frameId = Models.SDKUtils.findFrameIdByExecutionContext(
+        frames,
+        contextId,
+    );
+    if (!contextTarget || !frameId) {
+      throw new Error(
+          `No execution context found for the binding call + ${
+              JSON.stringify(
+                  event.data,
+                  )}`,
+      );
+    }
+    const model = contextTarget.model(SDK.ResourceTreeModel.ResourceTreeModel);
+    if (!model) {
+      throw new Error(
+          `ResourceTreeModel instance is missing for the target: ${contextTarget.id()}`,
+      );
+    }
+    const frame = model.frameForId(frameId);
+    if (!frame) {
+      throw new Error('Frame is not found');
+    }
+    this.#element.dispatchEvent(
+        new SelectorPickedEvent({
+          ...JSON.parse(event.data.payload),
+          ...Models.SDKUtils.getTargetFrameContext(contextTarget, frame),
+        }),
+    );
+    void this.stop();
+  };
+
+  readonly #scriptIdentifier = new Map<SDK.Target.Target, Protocol.Page.ScriptIdentifier>();
+  async #injectApplicationScript(target: SDK.Target.Target): Promise<void> {
+    const injectedScript = await Util.InjectedScript.get();
+    const script = `${injectedScript};DevToolsRecorder.startSelectorPicker({getAccessibleName, getAccessibleRole}, ${
+        JSON.stringify(this.#selectorAttribute ? this.#selectorAttribute : undefined)}, ${Util.isDebugBuild})`;
+    const [{identifier}] = await Promise.all([
+      target.pageAgent().invoke_addScriptToEvaluateOnNewDocument({
+        source: script,
+        worldName: Util.DEVTOOLS_RECORDER_WORLD_NAME,
+        includeCommandLineAPI: true,
+      }),
+      Models.SDKUtils.evaluateInAllFrames(Util.DEVTOOLS_RECORDER_WORLD_NAME, target, script),
+    ]);
+    this.#scriptIdentifier.set(target, identifier);
+  }
+  async #injectCleanupScript(target: SDK.Target.Target): Promise<void> {
+    const identifier = this.#scriptIdentifier.get(target);
+    Platform.assertNotNullOrUndefined(identifier);
+    this.#scriptIdentifier.delete(target);
+    await target.pageAgent().invoke_removeScriptToEvaluateOnNewDocument({identifier});
+    const script = 'DevToolsRecorder.stopSelectorPicker()';
+    await Models.SDKUtils.evaluateInAllFrames(Util.DEVTOOLS_RECORDER_WORLD_NAME, target, script);
+  }
+
+  async #addBindings(target: SDK.Target.Target): Promise<void> {
+    const model = target.model(SDK.RuntimeModel.RuntimeModel);
+    Platform.assertNotNullOrUndefined(model);
+    model.addEventListener(SDK.RuntimeModel.Events.BindingCalled, this.#handleBindingCalledEvent);
+    await model.addBinding({
+      name: BINDING_NAME,
+      executionContextName: Util.DEVTOOLS_RECORDER_WORLD_NAME,
+    });
+  }
+  async #removeBindings(target: SDK.Target.Target): Promise<void> {
+    await target.runtimeAgent().invoke_removeBinding({name: BINDING_NAME});
+    const model = target.model(SDK.RuntimeModel.RuntimeModel);
+    Platform.assertNotNullOrUndefined(model);
+    model.removeEventListener(SDK.RuntimeModel.Events.BindingCalled, this.#handleBindingCalledEvent);
+  }
+}
diff --git a/front_end/panels/recorder/controllers/controllers.ts b/front_end/panels/recorder/controllers/controllers.ts
new file mode 100644
index 0000000..638d826
--- /dev/null
+++ b/front_end/panels/recorder/controllers/controllers.ts
@@ -0,0 +1,7 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as SelectorPicker from './SelectorPicker.js';
+
+export {SelectorPicker};
diff --git a/front_end/panels/recorder/converters/BUILD.gn b/front_end/panels/recorder/converters/BUILD.gn
new file mode 100644
index 0000000..5147795
--- /dev/null
+++ b/front_end/panels/recorder/converters/BUILD.gn
@@ -0,0 +1,41 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_module.gni")
+
+devtools_module("converters") {
+  sources = [
+    "Converter.ts",
+    "ExtensionConverter.ts",
+    "JSONConverter.ts",
+    "LighthouseConverter.ts",
+    "PuppeteerConverter.ts",
+    "PuppeteerReplayConverter.ts",
+  ]
+
+  deps = [
+    "../../../core/common:bundle",
+    "../../../third_party/puppeteer-replay:bundle",
+    "../extensions:bundle",
+    "../models:bundle",
+  ]
+
+  public_deps = []
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "converters.ts"
+
+  deps = [ ":converters" ]
+
+  visibility = [
+    ":*",
+    "../:*",
+    "../../../../test/unittests/front_end/panels/recorder/*",
+    "../../../ui/components/docs/*",
+    "../components:*",
+  ]
+}
diff --git a/front_end/panels/recorder/converters/Converter.ts b/front_end/panels/recorder/converters/Converter.ts
new file mode 100644
index 0000000..309bb76
--- /dev/null
+++ b/front_end/panels/recorder/converters/Converter.ts
@@ -0,0 +1,15 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type * as Models from '../models/models.js';
+import type * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+
+export interface Converter {
+  stringify(flow: Models.Schema.UserFlow): Promise<[string, PuppeteerReplay.SourceMap|undefined]>;
+  stringifyStep(step: Models.Schema.Step): Promise<string>;
+  getFormatName(): string;
+  getFilename(flow: Models.Schema.UserFlow): string;
+  getMediaType(): string|undefined;
+  getId(): string;
+}
diff --git a/front_end/panels/recorder/converters/ExtensionConverter.ts b/front_end/panels/recorder/converters/ExtensionConverter.ts
new file mode 100644
index 0000000..baccdcb
--- /dev/null
+++ b/front_end/panels/recorder/converters/ExtensionConverter.ts
@@ -0,0 +1,69 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type * as Extension from '../extensions/extensions.js';
+import type * as Models from '../models/models.js';
+import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+
+import {type Converter} from './Converter.js';
+
+export const EXTENSION_PREFIX = 'extension_';
+
+export class ExtensionConverter implements Converter {
+  #idx: number;
+  #extension: Extension.ExtensionManager.Extension;
+
+  constructor(idx: number, extension: Extension.ExtensionManager.Extension) {
+    this.#idx = idx;
+    this.#extension = extension;
+  }
+
+  getId(): string {
+    return EXTENSION_PREFIX + this.#idx;
+  }
+
+  getFormatName(): string {
+    return this.#extension.getName();
+  }
+
+  getMediaType(): string|undefined {
+    return this.#extension.getMediaType();
+  }
+
+  getFilename(flow: Models.Schema.UserFlow): string {
+    const fileExtension = this.#mediaTypeToExtension(
+        this.#extension.getMediaType(),
+    );
+    return `${flow.title}${fileExtension}`;
+  }
+
+  async stringify(
+      flow: Models.Schema.UserFlow,
+      ): Promise<[string, PuppeteerReplay.SourceMap|undefined]> {
+    const text = await this.#extension.stringify(flow);
+    const sourceMap = PuppeteerReplay.parseSourceMap(text);
+    return [PuppeteerReplay.stripSourceMap(text), sourceMap];
+  }
+
+  async stringifyStep(step: Models.Schema.Step): Promise<string> {
+    return await this.#extension.stringifyStep(step);
+  }
+
+  #mediaTypeToExtension(mediaType: string|undefined): string {
+    // See https://www.iana.org/assignments/media-types/media-types.xhtml
+    switch (mediaType) {
+      case 'application/json':
+        return '.json';
+      case 'application/javascript':
+      case 'text/javascript':
+        return '.js';
+      case 'application/typescript':
+      case 'text/typescript':
+        return '.ts';
+      default:
+        // TODO: think of exhaustive mapping once the feature gets traction.
+        return '';
+    }
+  }
+}
diff --git a/front_end/panels/recorder/converters/JSONConverter.ts b/front_end/panels/recorder/converters/JSONConverter.ts
new file mode 100644
index 0000000..47105cb
--- /dev/null
+++ b/front_end/panels/recorder/converters/JSONConverter.ts
@@ -0,0 +1,50 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+import * as Models from '../models/models.js';
+
+import {type Converter} from './Converter.js';
+
+export class JSONConverter implements Converter {
+  #indent: string;
+
+  constructor(indent: string) {
+    this.#indent = indent;
+  }
+
+  getId(): string {
+    return Models.ConverterIds.ConverterIds.JSON;
+  }
+
+  getFormatName(): string {
+    return 'JSON';
+  }
+
+  getFilename(flow: Models.Schema.UserFlow): string {
+    return `${flow.title}.json`;
+  }
+
+  async stringify(
+      flow: Models.Schema.UserFlow,
+      ): Promise<[string, PuppeteerReplay.SourceMap|undefined]> {
+    const text = await PuppeteerReplay.stringify(flow, {
+      extension: new PuppeteerReplay.JSONStringifyExtension(),
+      indentation: this.#indent,
+    });
+    const sourceMap = PuppeteerReplay.parseSourceMap(text);
+    return [PuppeteerReplay.stripSourceMap(text), sourceMap];
+  }
+
+  async stringifyStep(step: Models.Schema.Step): Promise<string> {
+    return await PuppeteerReplay.stringifyStep(step, {
+      extension: new PuppeteerReplay.JSONStringifyExtension(),
+      indentation: this.#indent,
+    });
+  }
+
+  getMediaType(): string {
+    return 'application/json';
+  }
+}
diff --git a/front_end/panels/recorder/converters/LighthouseConverter.ts b/front_end/panels/recorder/converters/LighthouseConverter.ts
new file mode 100644
index 0000000..d97387c
--- /dev/null
+++ b/front_end/panels/recorder/converters/LighthouseConverter.ts
@@ -0,0 +1,51 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+import * as Models from '../models/models.js';
+
+import {type Converter} from './Converter.js';
+
+export class LighthouseConverter implements Converter {
+  #indent: string;
+
+  constructor(indent: string) {
+    this.#indent = indent;
+  }
+
+  getId(): string {
+    return Models.ConverterIds.ConverterIds.Lighthouse;
+  }
+
+  getFormatName(): string {
+    return 'Puppeteer (including Lighthouse analysis)';
+  }
+
+  getFilename(flow: Models.Schema.UserFlow): string {
+    return `${flow.title}.js`;
+  }
+
+  async stringify(
+      flow: Models.Schema.UserFlow,
+      ): Promise<[string, PuppeteerReplay.SourceMap|undefined]> {
+    const text = await PuppeteerReplay.stringify(flow, {
+      extension: new PuppeteerReplay.LighthouseStringifyExtension(),
+      indentation: this.#indent,
+    });
+    const sourceMap = PuppeteerReplay.parseSourceMap(text);
+    return [PuppeteerReplay.stripSourceMap(text), sourceMap];
+  }
+
+  async stringifyStep(step: Models.Schema.Step): Promise<string> {
+    // LighthouseStringifyExtension maintains state between steps, it cannot create a Lighthouse flow from a single step.
+    // If we need to stringify a single step, we should return just the Puppeteer code without Lighthouse analysis.
+    return await PuppeteerReplay.stringifyStep(step, {
+      indentation: this.#indent,
+    });
+  }
+
+  getMediaType(): string {
+    return 'text/javascript';
+  }
+}
diff --git a/front_end/panels/recorder/converters/PuppeteerConverter.ts b/front_end/panels/recorder/converters/PuppeteerConverter.ts
new file mode 100644
index 0000000..e991a56
--- /dev/null
+++ b/front_end/panels/recorder/converters/PuppeteerConverter.ts
@@ -0,0 +1,48 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+import * as Models from '../models/models.js';
+
+import {type Converter} from './Converter.js';
+
+export class PuppeteerConverter implements Converter {
+  #indent: string;
+
+  constructor(indent: string) {
+    this.#indent = indent;
+  }
+
+  getId(): string {
+    return Models.ConverterIds.ConverterIds.Puppeteer;
+  }
+
+  getFormatName(): string {
+    return 'Puppeteer';
+  }
+
+  getFilename(flow: Models.Schema.UserFlow): string {
+    return `${flow.title}.js`;
+  }
+
+  async stringify(
+      flow: Models.Schema.UserFlow,
+      ): Promise<[string, PuppeteerReplay.SourceMap|undefined]> {
+    const text = await PuppeteerReplay.stringify(flow, {
+      indentation: this.#indent,
+    });
+    const sourceMap = PuppeteerReplay.parseSourceMap(text);
+    return [PuppeteerReplay.stripSourceMap(text), sourceMap];
+  }
+
+  async stringifyStep(step: Models.Schema.Step): Promise<string> {
+    return await PuppeteerReplay.stringifyStep(step, {
+      indentation: this.#indent,
+    });
+  }
+
+  getMediaType(): string {
+    return 'text/javascript';
+  }
+}
diff --git a/front_end/panels/recorder/converters/PuppeteerReplayConverter.ts b/front_end/panels/recorder/converters/PuppeteerReplayConverter.ts
new file mode 100644
index 0000000..1bec41f
--- /dev/null
+++ b/front_end/panels/recorder/converters/PuppeteerReplayConverter.ts
@@ -0,0 +1,49 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+import * as Models from '../models/models.js';
+
+import {type Converter} from './Converter.js';
+
+export class PuppeteerReplayConverter implements Converter {
+  #indent: string;
+
+  constructor(indent: string) {
+    this.#indent = indent;
+  }
+
+  getId(): string {
+    return Models.ConverterIds.ConverterIds.Replay;
+  }
+
+  getFormatName(): string {
+    return '@puppeteer/replay';
+  }
+
+  getFilename(flow: Models.Schema.UserFlow): string {
+    return `${flow.title}.js`;
+  }
+
+  async stringify(
+      flow: Models.Schema.UserFlow,
+      ): Promise<[string, PuppeteerReplay.SourceMap|undefined]> {
+    const text = await PuppeteerReplay.stringify(flow, {
+      extension: new PuppeteerReplay.PuppeteerReplayStringifyExtension(),
+      indentation: this.#indent,
+    });
+    const sourceMap = PuppeteerReplay.parseSourceMap(text);
+    return [PuppeteerReplay.stripSourceMap(text), sourceMap];
+  }
+
+  async stringifyStep(step: Models.Schema.Step): Promise<string> {
+    return await PuppeteerReplay.stringifyStep(step, {
+      extension: new PuppeteerReplay.PuppeteerReplayStringifyExtension(),
+    });
+  }
+
+  getMediaType(): string {
+    return 'text/javascript';
+  }
+}
diff --git a/front_end/panels/recorder/converters/converters.ts b/front_end/panels/recorder/converters/converters.ts
new file mode 100644
index 0000000..aa6afea
--- /dev/null
+++ b/front_end/panels/recorder/converters/converters.ts
@@ -0,0 +1,19 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Converter from './Converter.js';
+import * as ExtensionConverter from './ExtensionConverter.js';
+import * as JSONConverter from './JSONConverter.js';
+import * as LighthouseConverter from './LighthouseConverter.js';
+import * as PuppeteerConverter from './PuppeteerConverter.js';
+import * as PuppeteerReplayConverter from './PuppeteerReplayConverter.js';
+
+export {
+  Converter,
+  ExtensionConverter,
+  JSONConverter,
+  LighthouseConverter,
+  PuppeteerConverter,
+  PuppeteerReplayConverter,
+};
diff --git a/front_end/panels/recorder/extensions/BUILD.gn b/front_end/panels/recorder/extensions/BUILD.gn
new file mode 100644
index 0000000..bafe872
--- /dev/null
+++ b/front_end/panels/recorder/extensions/BUILD.gn
@@ -0,0 +1,33 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_module.gni")
+
+devtools_module("extensions") {
+  sources = [ "ExtensionManager.ts" ]
+
+  deps = [
+    "../../../core/common:bundle",
+    "../../../models/extensions:bundle",
+  ]
+
+  public_deps = []
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "extensions.ts"
+
+  deps = [ ":extensions" ]
+
+  visibility = [
+    ":*",
+    "../:*",
+    "../components:*",
+    "../converters:*",
+    "../../../../test/unittests/front_end/panels/recorder/*",
+    "../../../ui/components/docs/*",
+  ]
+}
diff --git a/front_end/panels/recorder/extensions/ExtensionManager.ts b/front_end/panels/recorder/extensions/ExtensionManager.ts
new file mode 100644
index 0000000..d05a087
--- /dev/null
+++ b/front_end/panels/recorder/extensions/ExtensionManager.ts
@@ -0,0 +1,128 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../../core/common/common.js';
+import * as Extensions from '../../../models/extensions/extensions.js';
+
+let instance: ExtensionManager|null = null;
+
+export interface Extension {
+  getName(): string;
+  getMediaType(): string|undefined;
+  stringify(recording: Object): Promise<string>;
+  stringifyStep(step: Object): Promise<string>;
+  getCapabilities(): Array<'replay'|'export'>;
+  replay(recording: Object): void;
+}
+
+export class ExtensionManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
+  static instance(): ExtensionManager {
+    if (!instance) {
+      instance = new ExtensionManager();
+    }
+    return instance;
+  }
+
+  #views = new Map<string, ExtensionIframe>();
+
+  constructor() {
+    super();
+    this.attach();
+  }
+
+  attach(): void {
+    const pluginManager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
+    pluginManager.addEventListener(Extensions.RecorderPluginManager.Events.PluginAdded, this.#handlePlugin);
+    pluginManager.addEventListener(Extensions.RecorderPluginManager.Events.PluginRemoved, this.#handlePlugin);
+    pluginManager.addEventListener(Extensions.RecorderPluginManager.Events.ViewRegistered, this.#handleView);
+    for (const descriptor of pluginManager.views()) {
+      this.#handleView({data: descriptor});
+    }
+  }
+
+  detach(): void {
+    const pluginManager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
+    pluginManager.removeEventListener(Extensions.RecorderPluginManager.Events.PluginAdded, this.#handlePlugin);
+    pluginManager.removeEventListener(Extensions.RecorderPluginManager.Events.PluginRemoved, this.#handlePlugin);
+    pluginManager.removeEventListener(Extensions.RecorderPluginManager.Events.ViewRegistered, this.#handleView);
+    this.#views.clear();
+  }
+
+  extensions(): Extension[] {
+    return Extensions.RecorderPluginManager.RecorderPluginManager.instance().plugins();
+  }
+
+  getView(descriptorId: string): ExtensionIframe {
+    const view = this.#views.get(descriptorId);
+    if (!view) {
+      throw new Error('View not found');
+    }
+    return view;
+  }
+
+  #handlePlugin = (): void => {
+    this.dispatchEventToListeners(Events.ExtensionsUpdated, this.extensions());
+  };
+
+  #handleView = (event: {data: Extensions.RecorderPluginManager.ViewDescriptor}): void => {
+    const descriptor = event.data;
+    if (!this.#views.has(descriptor.id)) {
+      this.#views.set(descriptor.id, new ExtensionIframe(descriptor));
+    }
+  };
+}
+
+class ExtensionIframe {
+  #descriptor: Extensions.RecorderPluginManager.ViewDescriptor;
+  #iframe: HTMLIFrameElement;
+  #isShowing = false;
+  #isLoaded = false;
+
+  constructor(descriptor: Extensions.RecorderPluginManager.ViewDescriptor) {
+    this.#descriptor = descriptor;
+    this.#iframe = document.createElement('iframe');
+    this.#iframe.src = descriptor.pagePath;
+    this.#iframe.>
+  }
+
+  # void => {
+    this.#isLoaded = true;
+    if (this.#isShowing) {
+      this.#descriptor.onShown();
+    }
+  };
+
+  show(): void {
+    if (this.#isShowing) {
+      return;
+    }
+    this.#isShowing = true;
+    if (this.#isLoaded) {
+      this.#descriptor.onShown();
+    }
+  }
+
+  hide(): void {
+    if (!this.#isShowing) {
+      return;
+    }
+    this.#isShowing = false;
+    this.#isLoaded = false;
+    this.#descriptor.onHidden();
+  }
+
+  frame(): HTMLIFrameElement {
+    return this.#iframe;
+  }
+}
+
+// TODO(crbug.com/1167717): Make this a const enum again
+// eslint-disable-next-line rulesdir/const_enum
+export enum Events {
+  ExtensionsUpdated = 'extensionsUpdated',
+}
+
+export type EventTypes = {
+  [Events.ExtensionsUpdated]: Extension[],
+};
diff --git a/front_end/panels/recorder/extensions/extensions.ts b/front_end/panels/recorder/extensions/extensions.ts
new file mode 100644
index 0000000..5409f21
--- /dev/null
+++ b/front_end/panels/recorder/extensions/extensions.ts
@@ -0,0 +1,7 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ExtensionManager from './ExtensionManager.js';
+
+export {ExtensionManager};
diff --git a/front_end/panels/recorder/images/BUILD.gn b/front_end/panels/recorder/images/BUILD.gn
new file mode 100644
index 0000000..5266bd4
--- /dev/null
+++ b/front_end/panels/recorder/images/BUILD.gn
@@ -0,0 +1,39 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../scripts/build/ninja/devtools_pre_built.gni")
+
+image_files = [
+  "abort_icon.svg",
+  "advance_icon.svg",
+  "feedback_icon.svg",
+  "minus_icon.svg",
+  "path_icon.svg",
+  "record_icon.svg",
+  "select_icon.svg",
+  "step_icon.svg",
+  "throttling_icon.svg",
+  "more_icon.svg",
+  "extension_icon.svg",
+  "close_icon.svg",
+  "delete_icon.svg",
+  "edit_icon.svg",
+  "export_icon.svg",
+  "import_icon.svg",
+  "play_icon.svg",
+  "plus_icon.svg",
+  "question_icon.svg",
+  "select-arrow-icon.svg",
+  "settings_cog_icon.svg",
+]
+
+devtools_pre_built("images") {
+  sources = []
+  data = []
+  foreach(image, image_files) {
+    sources += [ image ]
+    data += [ "$target_gen_dir/$image" ]
+  }
+}
diff --git a/front_end/panels/recorder/images/abort_icon.svg b/front_end/panels/recorder/images/abort_icon.svg
new file mode 100644
index 0000000..bc433b6
--- /dev/null
+++ b/front_end/panels/recorder/images/abort_icon.svg
@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17 7H14L14 17H17V7ZM14 6C13.4477 6 13 6.44772 13 7V17C13 17.5523 13.4477 18 14 18H17C17.5523 18 18 17.5523 18 17V7C18 6.44772 17.5523 6 17 6H14Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M10 7H7L7 17H10V7ZM7 6C6.44772 6 6 6.44772 6 7V17C6 17.5523 6.44772 18 7 18H10C10.5523 18 11 17.5523 11 17V7C11 6.44772 10.5523 6 10 6H7Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/advance_icon.svg b/front_end/panels/recorder/images/advance_icon.svg
new file mode 100644
index 0000000..5b223a6
--- /dev/null
+++ b/front_end/panels/recorder/images/advance_icon.svg
@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8 7H6V17H8V7ZM6 6C5.44772 6 5 6.44772 5 7V17C5 17.5523 5.44772 18 6 18H8C8.55228 18 9 17.5523 9 17V7C9 6.44772 8.55228 6 8 6H6Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M19.1417 12L12 7.44019L12 16.5598L19.1417 12ZM20.3399 12.4214C20.6479 12.2248 20.6479 11.7751 20.3399 11.5786L11.7691 6.1063C11.4362 5.8938 11 6.13285 11 6.52773L11 17.4722C11 17.8671 11.4362 18.1062 11.7691 17.8937L20.3399 12.4214Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/close_icon.svg b/front_end/panels/recorder/images/close_icon.svg
new file mode 100644
index 0000000..6e5eb3a
--- /dev/null
+++ b/front_end/panels/recorder/images/close_icon.svg
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13 4.33L11.67 3L8 6.67L4.33 3L3 4.33L6.67 8L3 11.67L4.33 13L8 9.33L11.67 13L13 11.67L9.33 8L13 4.33Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/delete_icon.svg b/front_end/panels/recorder/images/delete_icon.svg
new file mode 100644
index 0000000..929a30f
--- /dev/null
+++ b/front_end/panels/recorder/images/delete_icon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M18 8V7H16H15V6C15 5.44772 14.5523 5 14 5H10C9.44772 5 9 5.44771 9 6V7H8H6V8H7L7 19C7 19.5523 7.44772 20 8 20H16C16.5523 20 17 19.5523 17 19V8H18ZM14 6V7H10V6H14ZM10 8H8V19H16V8H14H10ZM10 10V17H11V10H10ZM13 10V17H14V10H13Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/edit_icon.svg b/front_end/panels/recorder/images/edit_icon.svg
new file mode 100644
index 0000000..b0cb3ba
--- /dev/null
+++ b/front_end/panels/recorder/images/edit_icon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.8536 4.64645C16.6583 4.45118 16.3417 4.45118 16.1464 4.64645L5.14645 15.6464C5.05268 15.7402 5 15.8674 5 16V18.5C5 18.7761 5.22386 19 5.5 19H8C8.13261 19 8.25979 18.9473 8.35355 18.8536L19.3536 7.85355C19.5488 7.65829 19.5488 7.34171 19.3536 7.14645L16.8536 4.64645ZM14.9571 7.25003L16.5 5.70711L18.2929 7.5L16.75 9.04292L14.9571 7.25003ZM14.25 7.95714L6 16.2071V18H7.79289L16.0429 9.75003L14.25 7.95714Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/export_icon.svg b/front_end/panels/recorder/images/export_icon.svg
new file mode 100644
index 0000000..79c3b68
--- /dev/null
+++ b/front_end/panels/recorder/images/export_icon.svg
@@ -0,0 +1,4 @@
+<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path d="M12.504 14.293l3.646-3.646.707.707-4.853 4.853-4.854-4.853.707-.707 3.647 3.646V5h1v9.293z" fill="#000"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M5 18v-2h1v2h12v-2h1v2a1 1 0 01-1 1H6a1 1 0 01-1-1z" fill="#000"/>
+</svg>
diff --git a/front_end/panels/recorder/images/extension_icon.svg b/front_end/panels/recorder/images/extension_icon.svg
new file mode 100644
index 0000000..d294e4b
--- /dev/null
+++ b/front_end/panels/recorder/images/extension_icon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#041E49"><path d="M9 42q-1.25 0-2.125-.875T6 39v-8.8q2.2-.25 3.775-1.725Q11.35 27 11.35 24.85t-1.575-3.625Q8.2 19.75 6 19.5v-8.8q0-1.25.875-2.125T9 7.7h8.85q.55-1.95 1.975-3.325Q21.25 3 23.25 3q2 0 3.425 1.375Q28.1 5.75 28.65 7.7h8.65q1.25 0 2.125.875T40.3 10.7v8.65q1.95.55 3.25 2.075 1.3 1.525 1.3 3.525 0 1.9-1.325 3.325Q42.2 29.7 40.3 30.2V39q0 1.25-.875 2.125T37.3 42h-8.8q-.25-2.2-1.725-3.775Q25.3 36.65 23.15 36.65t-3.625 1.575Q18.05 39.8 17.8 42Z"/></svg>
\ No newline at end of file
diff --git a/front_end/panels/recorder/images/feedback_icon.svg b/front_end/panels/recorder/images/feedback_icon.svg
new file mode 100644
index 0000000..140e17e
--- /dev/null
+++ b/front_end/panels/recorder/images/feedback_icon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.3335 5.83325H6.66683C5.9335 5.83325 5.34016 6.43325 5.34016 7.16659L5.3335 19.1666L8.00016 16.4999H17.3335C18.0668 16.4999 18.6668 15.8999 18.6668 15.1666V7.16659C18.6668 6.43325 18.0668 5.83325 17.3335 5.83325ZM17.3335 15.1666H7.44683L7.0535 15.5599L6.66683 15.9466V7.16659H17.3335V15.1666ZM11.3335 12.4999H12.6668V13.8333H11.3335V12.4999ZM11.3335 8.49992H12.6668V11.1666H11.3335V8.49992Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/import_icon.svg b/front_end/panels/recorder/images/import_icon.svg
new file mode 100644
index 0000000..5bf24a7
--- /dev/null
+++ b/front_end/panels/recorder/images/import_icon.svg
@@ -0,0 +1,4 @@
+<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path d="M11.504 6.914l-3.647 3.647-.707-.707L12.004 5l4.853 4.854-.707.707-3.646-3.647v9.293h-1V6.914z" fill="#000"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M5 18v-2h1v2h12v-2h1v2a1 1 0 01-1 1H6a1 1 0 01-1-1z" fill="#000"/>
+</svg>
diff --git a/front_end/panels/recorder/images/minus_icon.svg b/front_end/panels/recorder/images/minus_icon.svg
new file mode 100644
index 0000000..f9f45b3
--- /dev/null
+++ b/front_end/panels/recorder/images/minus_icon.svg
@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 5.5H2V6.5H10V5.5Z" fill="#202124"/>
+</svg>
diff --git a/front_end/panels/recorder/images/more_icon.svg b/front_end/panels/recorder/images/more_icon.svg
new file mode 100644
index 0000000..66b28dc
--- /dev/null
+++ b/front_end/panels/recorder/images/more_icon.svg
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.5 3.01461C9.50193 3.21281 9.46455 3.40943 9.39004 3.5931C9.31552 3.77678 9.20533 3.94386 9.06585 4.0847C8.92638 4.22553 8.76038 4.33733 8.57744 4.41363C8.39449 4.48993 8.19824 4.52921 8.00003 4.52921C7.80181 4.52921 7.60558 4.48993 7.42264 4.41363C7.2397 4.33733 7.07367 4.22553 6.9342 4.0847C6.79472 3.94386 6.68455 3.77678 6.61004 3.5931C6.53552 3.40943 6.49815 3.21281 6.50007 3.01461C6.49815 2.8164 6.53552 2.61978 6.61004 2.43611C6.68455 2.25243 6.79472 2.08535 6.9342 1.94451C7.07367 1.80368 7.2397 1.69188 7.42264 1.61558C7.60558 1.53928 7.80181 1.5 8.00003 1.5C8.19824 1.5 8.39449 1.53928 8.57744 1.61558C8.76038 1.69188 8.92638 1.80368 9.06585 1.94451C9.20533 2.08535 9.31552 2.25243 9.39004 2.43611C9.46455 2.61978 9.50193 2.8164 9.5 3.01461V3.01461Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.5 8.01461C9.50193 8.21281 9.46455 8.40943 9.39004 8.5931C9.31552 8.77678 9.20533 8.94386 9.06585 9.0847C8.92638 9.22553 8.76038 9.33733 8.57744 9.41363C8.39449 9.48993 8.19824 9.52921 8.00003 9.52921C7.80181 9.52921 7.60558 9.48993 7.42264 9.41363C7.2397 9.33733 7.07367 9.22553 6.9342 9.0847C6.79472 8.94386 6.68455 8.77678 6.61004 8.5931C6.53552 8.40943 6.49815 8.21281 6.50007 8.01461C6.49815 7.8164 6.53552 7.61978 6.61004 7.43611C6.68455 7.25243 6.79472 7.08535 6.9342 6.94451C7.07367 6.80368 7.2397 6.69188 7.42264 6.61558C7.60558 6.53928 7.80181 6.5 8.00003 6.5C8.19824 6.5 8.39449 6.53928 8.57744 6.61558C8.76038 6.69188 8.92638 6.80368 9.06585 6.94451C9.20533 7.08535 9.31552 7.25243 9.39004 7.43611C9.46455 7.61978 9.50193 7.8164 9.5 8.01461V8.01461Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.5 13.0146C9.50193 13.2128 9.46455 13.4094 9.39004 13.5931C9.31552 13.7768 9.20533 13.9439 9.06585 14.0847C8.92638 14.2255 8.76038 14.3373 8.57744 14.4136C8.39449 14.4899 8.19824 14.5292 8.00003 14.5292C7.80181 14.5292 7.60558 14.4899 7.42264 14.4136C7.2397 14.3373 7.07367 14.2255 6.9342 14.0847C6.79472 13.9439 6.68455 13.7768 6.61004 13.5931C6.53552 13.4094 6.49815 13.2128 6.50007 13.0146C6.49815 12.8164 6.53552 12.6198 6.61004 12.4361C6.68455 12.2524 6.79472 12.0854 6.9342 11.9445C7.07367 11.8037 7.2397 11.6919 7.42264 11.6156C7.60558 11.5393 7.80181 11.5 8.00003 11.5C8.19824 11.5 8.39449 11.5393 8.57744 11.6156C8.76038 11.6919 8.92638 11.8037 9.06585 11.9445C9.20533 12.0854 9.31552 12.2524 9.39004 12.4361C9.46455 12.6198 9.50193 12.8164 9.5 13.0146V13.0146Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/path_icon.svg b/front_end/panels/recorder/images/path_icon.svg
new file mode 100644
index 0000000..8ec009e
--- /dev/null
+++ b/front_end/panels/recorder/images/path_icon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.5217 14.4348C16.5232 14.4348 15.6783 15.08 15.3557 15.971H11.3768C10.5319 15.971 9.84059 15.2797 9.84059 14.4348C9.84059 13.5899 10.5319 12.8986 11.3768 12.8986H12.9131C14.6106 12.8986 15.9855 11.5236 15.9855 9.8261C15.9855 8.12857 14.6106 6.75364 12.9131 6.75364H8.93421C8.6116 5.86262 7.76667 5.21741 6.76812 5.21741C5.49305 5.21741 4.46378 6.24668 4.46378 7.52175C4.46378 8.79683 5.49305 9.8261 6.76812 9.8261C7.76667 9.8261 8.6116 9.18088 8.93421 8.28987H12.9131C13.758 8.28987 14.4493 8.98117 14.4493 9.8261C14.4493 10.671 13.758 11.3623 12.9131 11.3623H11.3768C9.67928 11.3623 8.30436 12.7373 8.30436 14.4348C8.30436 16.1323 9.67928 17.5073 11.3768 17.5073H15.3557C15.6706 18.3983 16.5155 19.0435 17.5217 19.0435C18.7968 19.0435 19.8261 18.0142 19.8261 16.7391C19.8261 15.4641 18.7968 14.4348 17.5217 14.4348ZM6.76812 8.28987C6.34566 8.28987 6.00001 7.94422 6.00001 7.52175C6.00001 7.09929 6.34566 6.75364 6.76812 6.75364C7.19059 6.75364 7.53624 7.09929 7.53624 7.52175C7.53624 7.94422 7.19059 8.28987 6.76812 8.28987Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/play_icon.svg b/front_end/panels/recorder/images/play_icon.svg
new file mode 100644
index 0000000..cb6e375
--- /dev/null
+++ b/front_end/panels/recorder/images/play_icon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0343 12L8 7.25247V16.7476L16.0343 12ZM17.2715 12.4305C17.599 12.237 17.599 11.7631 17.2715 11.5696L7.75436 5.94579C7.42106 5.74883 7 5.9891 7 6.37625V17.6238C7 18.011 7.42106 18.2512 7.75436 18.0543L17.2715 12.4305Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/plus_icon.svg b/front_end/panels/recorder/images/plus_icon.svg
new file mode 100644
index 0000000..4dffe84
--- /dev/null
+++ b/front_end/panels/recorder/images/plus_icon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13 5H12V11H6V12H12V18H13V12H19V11H13V5Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/question_icon.svg b/front_end/panels/recorder/images/question_icon.svg
new file mode 100755
index 0000000..3e980a4
--- /dev/null
+++ b/front_end/panels/recorder/images/question_icon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></svg>
\ No newline at end of file
diff --git a/front_end/panels/recorder/images/record_icon.svg b/front_end/panels/recorder/images/record_icon.svg
new file mode 100644
index 0000000..9b2aca1
--- /dev/null
+++ b/front_end/panels/recorder/images/record_icon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M7 15C8.65685 15 10 13.6569 10 12C10 10.3431 8.65685 9 7 9C5.34315 9 4 10.3431 4 12C4 13.6569 5.34315 15 7 15ZM9.64582 15C10.4762 14.2671 11 13.1947 11 12C11 9.79086 9.20914 8 7 8C4.79086 8 3 9.79086 3 12C3 14.2091 4.79086 16 7 16H17C19.2091 16 21 14.2091 21 12C21 9.79086 19.2091 8 17 8C14.7909 8 13 9.79086 13 12C13 13.1947 13.5238 14.2671 14.3542 15H9.64582ZM17 15C18.6569 15 20 13.6569 20 12C20 10.3431 18.6569 9 17 9C15.3431 9 14 10.3431 14 12C14 13.6569 15.3431 15 17 15Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/select-arrow-icon.svg b/front_end/panels/recorder/images/select-arrow-icon.svg
new file mode 100644
index 0000000..393aea2
--- /dev/null
+++ b/front_end/panels/recorder/images/select-arrow-icon.svg
@@ -0,0 +1,3 @@
+<svg width="8" height="4" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path d="M8 0H0l4 4 4-4z" fill="#202124"/>
+</svg>
\ No newline at end of file
diff --git a/front_end/panels/recorder/images/select_icon.svg b/front_end/panels/recorder/images/select_icon.svg
new file mode 100644
index 0000000..781361f
--- /dev/null
+++ b/front_end/panels/recorder/images/select_icon.svg
@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.49984 9.16675H3.0415C2.45817 9.16675 2.1665 8.87508 2.1665 8.29175V3.04175C2.1665 2.45841 2.45817 2.16675 3.0415 2.16675H8.2915C9.1665 2.16675 9.1665 3.02296 9.1665 3.04175V4.50008H8.58317V2.75008H2.74984V8.58342H4.49984V9.16675ZM9.74984 6.25008L7.99984 7.41675L9.74984 9.16675L9.1665 9.75008L7.4165 8.00008L6.24984 9.75008L5.08317 5.08341L9.74984 6.25008Z" fill="#202124"/>
+</svg>
diff --git a/front_end/panels/recorder/images/settings_cog_icon.svg b/front_end/panels/recorder/images/settings_cog_icon.svg
new file mode 100644
index 0000000..e96d49e
--- /dev/null
+++ b/front_end/panels/recorder/images/settings_cog_icon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.4906 5.33647L11.4887 5.3406L11.4906 5.3406L11.4926 5.3406L11.4906 5.33647ZM9.31039 5.56622C9.37433 5.61061 9.44239 5.64357 9.51232 5.6657C9.76485 5.57996 10.0248 5.51019 10.2908 5.45757C10.3406 5.40329 10.3832 5.34051 10.4165 5.26983L10.8122 4.43029C11.0825 3.85657 11.8987 3.85657 12.169 4.43029L12.5647 5.26984C12.598 5.34051 12.6406 5.40329 12.6904 5.45757C12.9564 5.51019 13.2164 5.57996 13.4689 5.6657C13.5388 5.64357 13.6069 5.61061 13.6708 5.56622L14.4332 5.03697C14.9542 4.6753 15.661 5.08338 15.6083 5.71541L15.5312 6.6403C15.5246 6.71881 15.5303 6.79513 15.5466 6.86754C15.7476 7.0441 15.9371 7.23357 16.1137 7.43465C16.1861 7.45089 16.2624 7.45659 16.3409 7.45004L17.2658 7.3729C17.8978 7.32018 18.3059 8.027 17.9442 8.54799L17.415 9.31039C17.3706 9.37433 17.3376 9.44239 17.3155 9.51232C17.4012 9.76485 17.471 10.0248 17.5236 10.2908C17.5779 10.3406 17.6407 10.3832 17.7114 10.4165L18.5509 10.8122C19.1246 11.0825 19.1246 11.8987 18.5509 12.169L17.7114 12.5647C17.6407 12.598 17.5779 12.6406 17.5236 12.6904C17.471 12.9564 17.4012 13.2164 17.3155 13.4689C17.3376 13.5388 17.3706 13.6069 17.415 13.6708L17.9442 14.4332C18.3059 14.9542 17.8978 15.661 17.2658 15.6083L16.3409 15.5312C16.2624 15.5246 16.1861 15.5303 16.1137 15.5466C15.9371 15.7476 15.7476 15.9371 15.5466 16.1137C15.5303 16.1861 15.5246 16.2624 15.5312 16.3409L15.6083 17.2658C15.661 17.8978 14.9542 18.3059 14.4332 17.9442L13.6708 17.415C13.6069 17.3706 13.5388 17.3376 13.4689 17.3155C13.2164 17.4012 12.9565 17.471 12.6904 17.5236C12.6406 17.5779 12.598 17.6407 12.5647 17.7114L12.169 18.5509C11.8987 19.1246 11.0825 19.1246 10.8122 18.5509L10.4165 17.7114C10.3832 17.6407 10.3406 17.5779 10.2908 17.5236C10.0248 17.471 9.76485 17.4012 9.51232 17.3155C9.44239 17.3376 9.37433 17.3706 9.31039 17.415L8.54799 17.9442C8.027 18.3059 7.32018 17.8978 7.3729 17.2658L7.45004 16.3409C7.45659 16.2624 7.45089 16.1861 7.43465 16.1137C7.23357 15.9371 7.0441 15.7476 6.86754 15.5466C6.79513 15.5303 6.71881 15.5246 6.6403 15.5312L5.71541 15.6083C5.08338 15.661 4.6753 14.9542 5.03697 14.4332L5.56622 13.6708C5.61061 13.6069 5.64357 13.5388 5.6657 13.4689C5.57996 13.2164 5.51019 12.9564 5.45757 12.6904C5.40329 12.6406 5.34051 12.598 5.26983 12.5647L4.43029 12.169C3.85657 11.8987 3.85657 11.0825 4.43029 10.8122L5.26984 10.4165C5.34051 10.3832 5.40329 10.3406 5.45757 10.2908C5.51019 10.0248 5.57996 9.76485 5.6657 9.51232C5.64357 9.44239 5.61061 9.37433 5.56622 9.31039L5.03697 8.54799C4.6753 8.027 5.08338 7.32018 5.71541 7.3729L6.6403 7.45004C6.71881 7.45659 6.79513 7.45089 6.86754 7.43465C7.0441 7.23356 7.23356 7.0441 7.43465 6.86754C7.45089 6.79513 7.45659 6.71881 7.45004 6.6403L7.3729 5.71541C7.32018 5.08338 8.027 4.6753 8.54799 5.03697L9.31039 5.56622ZM14.5674 6.16439L14.5648 6.16293L14.5677 6.16096L14.5674 6.16439ZM16.8183 8.41636L16.8168 8.41382L16.8202 8.41353L16.8183 8.41636ZM17.6406 11.4906V11.4887L17.6447 11.4906L17.6406 11.4926V11.4906ZM16.8168 14.5674L16.8183 14.5648L16.8202 14.5677L16.8168 14.5674ZM14.5648 16.8183L14.5674 16.8168L14.5677 16.8202L14.5648 16.8183ZM11.4906 17.6406H11.4926L11.4906 17.6447L11.4887 17.6406H11.4906ZM8.41382 16.8168L8.41636 16.8183L8.41353 16.8202L8.41382 16.8168ZM6.16293 14.5648L6.16439 14.5674L6.16096 14.5677L6.16293 14.5648ZM5.3406 11.4906L5.3406 11.4926L5.33647 11.4906L5.3406 11.4887L5.3406 11.4906ZM6.16439 8.41382L6.16293 8.41636L6.16096 8.41353L6.16439 8.41382ZM8.41636 6.16293L8.41382 6.16439L8.41353 6.16096L8.41636 6.16293ZM11.4906 6.6406C8.81202 6.6406 6.6406 8.81202 6.6406 11.4906C6.6406 14.1692 8.81202 16.3406 11.4906 16.3406C14.1692 16.3406 16.3406 14.1692 16.3406 11.4906C16.3406 8.81202 14.1692 6.6406 11.4906 6.6406ZM12.4906 11.4906C12.4906 12.0429 12.0429 12.4906 11.4906 12.4906C10.9383 12.4906 10.4906 12.0429 10.4906 11.4906C10.4906 10.9383 10.9383 10.4906 11.4906 10.4906C12.0429 10.4906 12.4906 10.9383 12.4906 11.4906ZM13.4906 11.4906C13.4906 12.5952 12.5952 13.4906 11.4906 13.4906C10.386 13.4906 9.4906 12.5952 9.4906 11.4906C9.4906 10.386 10.386 9.4906 11.4906 9.4906C12.5952 9.4906 13.4906 10.386 13.4906 11.4906Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/step_icon.svg b/front_end/panels/recorder/images/step_icon.svg
new file mode 100644
index 0000000..00c8260
--- /dev/null
+++ b/front_end/panels/recorder/images/step_icon.svg
@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11 16C11.5523 16 12 15.5523 12 15C12 14.4477 11.5523 14 11 14C10.4477 14 10 14.4477 10 15C10 15.5523 10.4477 16 11 16ZM11 17C12.1046 17 13 16.1046 13 15C13 13.8954 12.1046 13 11 13C9.89543 13 9 13.8954 9 15C9 16.1046 9.89543 17 11 17Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 15C3.5 10.8579 6.85786 7.5 11 7.5C14.7363 7.5 17.8344 10.2321 18.4057 13.8074L16.3612 12.5545L15.8387 13.4072L18.4774 15.0242C18.9483 15.3127 19.564 15.1649 19.8526 14.694L21.4696 12.0553L20.6169 11.5328L19.3777 13.5551C18.6916 9.5489 15.2019 6.5 11 6.5C6.30558 6.5 2.5 10.3056 2.5 15H3.5Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/images/throttling_icon.svg b/front_end/panels/recorder/images/throttling_icon.svg
new file mode 100644
index 0000000..3ca2432
--- /dev/null
+++ b/front_end/panels/recorder/images/throttling_icon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M6.2952 9.74946C5.68959 10.7971 5.33984 12.0274 5.33984 13.3492C5.33984 15.1404 5.98472 16.7687 7.04026 18H5.17941C4.33175 16.6635 3.83984 15.0643 3.83984 13.3492C3.83984 8.70143 7.46443 4.88086 11.9994 4.88086C16.5344 4.88086 20.1589 8.70143 20.1589 13.3492C20.1589 15.0643 19.667 16.6635 18.8194 18H16.9585C18.0141 16.7687 18.6589 15.1404 18.6589 13.3492C18.6589 12.0281 18.3095 10.7983 17.7045 9.75104L16.6292 11.4083L15.3708 10.5918L16.7502 8.4661C15.7035 7.35285 14.3038 6.608 12.75 6.42484V9.00003H11.25V6.4247C9.69635 6.60754 8.29669 7.35196 7.24989 8.46475L8.62928 10.592L7.37072 11.4081L6.2952 9.74946ZM15.6462 12.8807C15.8565 12.5238 15.7376 12.0641 15.3807 11.8538C15.0238 11.6436 14.564 11.7625 14.3538 12.1193L12.5977 15.1002C12.4053 15.0551 12.2054 15.0314 12.0003 15.0314C10.9779 15.0314 10.0845 15.6214 9.60196 16.5C9.36026 16.9402 9.22168 17.4528 9.22168 18H14.7788C14.7788 17.4528 14.6403 16.9402 14.3986 16.5C14.2638 16.2545 14.0969 16.0316 13.9041 15.8378L15.6462 12.8807Z" fill="black"/>
+</svg>
diff --git a/front_end/panels/recorder/injected/BUILD.gn b/front_end/panels/recorder/injected/BUILD.gn
new file mode 100644
index 0000000..e0ab1d4
--- /dev/null
+++ b/front_end/panels/recorder/injected/BUILD.gn
@@ -0,0 +1,94 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../scripts/build/ninja/copy.gni")
+import(
+    "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_module.gni")
+import("../../../../scripts/build/ninja/node.gni")
+
+devtools_module("injected") {
+  sources = [
+    "Logger.ts",
+    "MonotonicArray.ts",
+    "RecordingClient.ts",
+    "SelectorComputer.ts",
+    "SelectorPicker.ts",
+    "Step.ts",
+    "selectors/ARIASelector.ts",
+    "selectors/CSSSelector.ts",
+    "selectors/PierceSelector.ts",
+    "selectors/Selector.ts",
+    "selectors/TextSelector.ts",
+    "selectors/XPath.ts",
+    "util.ts",
+  ]
+
+  visibility = [
+    ":*",
+    "../:*",
+    "../../../../test/interactions/front_end/panels/recorder/*",
+    "../../../../test/unittests/front_end/panels/recorder/*",
+    "../../../ui/components/docs/recorder_injected/*",
+    "../controllers:*",
+    "../models:*",
+  ]
+
+  deps = [
+    "../../../third_party/puppeteer:puppeteer",
+    "../../../third_party/puppeteer-replay:bundle",
+  ]
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "injected.ts"
+
+  visibility = [
+    ":*",
+    "../:*",
+    "../../../../test/interactions/panels/recorder/*",
+    "../../../../test/unittests/front_end/panels/recorder/*",
+    "../../../ui/components/docs/recorder_injected/*",
+    "../controllers:*",
+    "../models:*",
+  ]
+
+  deps = [ ":injected" ]
+}
+
+node_action("bundled_library") {
+  script = "node_modules/rollup/dist/bin/rollup"
+
+  _bundled_entrypoint = target_gen_dir + "/injected.js"
+  _output_file_location = target_gen_dir + "/injected.generated.js"
+
+  inputs = [
+    _bundled_entrypoint,
+    "rollup.config.js",
+  ]
+
+  deps = [ ":bundle" ]
+
+  args = [
+    # TODO(crbug.com/1098074): We need to hide warnings that are written stderr,
+    # as Chromium does not process the returncode of the subprocess correctly
+    # and instead looks if `stderr` is empty.
+    "--silent",
+    "--config",
+    rebase_path("rollup.config.js", root_build_dir),
+    "--input",
+    rebase_path(_bundled_entrypoint, root_build_dir),
+    "--file",
+    rebase_path(_output_file_location, root_build_dir),
+  ]
+
+  if (is_debug) {
+    args += [ "--environment DEBUG_INJECTED" ]
+  }
+
+  outputs = [ _output_file_location ]
+  metadata = {
+    grd_files = [ _output_file_location ]
+  }
+}
diff --git a/front_end/panels/recorder/injected/Logger.ts b/front_end/panels/recorder/injected/Logger.ts
new file mode 100644
index 0000000..24dc1cd
--- /dev/null
+++ b/front_end/panels/recorder/injected/Logger.ts
@@ -0,0 +1,38 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const noop = (): void => void 0;
+
+export class Logger {
+  #log: (...args: unknown[]) => void;
+  #time: (label: string) => void;
+  #timeEnd: (label: string) => void;
+
+  constructor(level?: 'silent'|'debug') {
+    switch (level) {
+      case 'silent':
+        this.#log = noop;
+        this.#time = noop;
+        this.#timeEnd = noop;
+        break;
+      default:
+        // eslint-disable-next-line no-console
+        this.#log = console.log;
+        this.#time = console.time;
+        this.#timeEnd = console.timeEnd;
+        break;
+    }
+  }
+
+  log(...args: unknown[]): void {
+    this.#log(...args);
+  }
+
+  timed<T>(label: string, action: () => T): T {
+    this.#time(label);
+    const value = action();
+    this.#timeEnd(label);
+    return value;
+  }
+}
diff --git a/front_end/panels/recorder/injected/MonotonicArray.ts b/front_end/panels/recorder/injected/MonotonicArray.ts
new file mode 100644
index 0000000..8fd20ae
--- /dev/null
+++ b/front_end/panels/recorder/injected/MonotonicArray.ts
@@ -0,0 +1,18 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export class MonotonicArray<T extends object> {
+  #values = new WeakMap<T, number>();
+  #nextId = 1;
+
+  getOrInsert = (node: T): number => {
+    const value = this.#values.get(node);
+    if (value !== undefined) {
+      return value;
+    }
+    this.#values.set(node, this.#nextId);
+    this.#nextId++;
+    return this.#nextId - 1;
+  };
+}
diff --git a/front_end/panels/recorder/injected/README.md b/front_end/panels/recorder/injected/README.md
new file mode 100644
index 0000000..0e2faed
--- /dev/null
+++ b/front_end/panels/recorder/injected/README.md
@@ -0,0 +1,8 @@
+# Recording Client
+
+This folder contains the recorder client code that gets injected into target pages.
+The code consists of a single file where the code is defined within a single closure
+so that it's serializable.
+
+In the future, we might want to split into submodules and find a way to bundle everything
+into something serializable.
diff --git a/front_end/panels/recorder/injected/RecordingClient.ts b/front_end/panels/recorder/injected/RecordingClient.ts
new file mode 100644
index 0000000..da676e8
--- /dev/null
+++ b/front_end/panels/recorder/injected/RecordingClient.ts
@@ -0,0 +1,301 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+import {
+  type Schema,
+  type SelectorType,
+  type StepType,
+} from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+import {Logger} from './Logger.js';
+import {SelectorComputer} from './SelectorComputer.js';
+import {type Step} from './Step.js';
+import {type AccessibilityBindings} from './selectors/ARIASelector.js';
+import {queryCSSSelectorAll} from './selectors/CSSSelector.js';
+import {type Selector} from './selectors/Selector.js';
+import {
+  getClickableTargetFromEvent,
+  haultImmediateEvent,
+  assert,
+  createClickAttributes,
+} from './util.js';
+
+declare global {
+  interface Window {
+    stopShortcut(payload: string): void;
+    addStep(step: string): void;
+  }
+}
+
+interface Shortcut {
+  meta: boolean;
+  ctrl: boolean;
+  shift: boolean;
+  alt: boolean;
+  keyCode: number;
+}
+
+export interface RecordingClientOptions {
+  debug: boolean;
+  allowUntrustedEvents: boolean;
+  selectorAttribute?: string;
+  selectorTypesToRecord: SelectorType[];
+  stopShortcuts?: Shortcut[];
+}
+
+/**
+ * Determines whether an element is ignorable as an input.
+ *
+ * This is only called on input-like elements (elements that emit the `input`
+ * event).
+ *
+ * With every `if` statement, please write a comment above explaining your
+ * reasoning for ignoring the event.
+ */
+const isIgnorableInputElement = (element: Element): boolean => {
+  if (element instanceof HTMLInputElement) {
+    switch (element.type) {
+      // Checkboxes are always changed as a consequence of another type of action
+      // such as the keyboard or mouse. As such, we can safely ignore these
+      // elements.
+      case 'checkbox':
+        return true;
+        // Radios are always changed as a consequence of another type of action
+        // such as the keyboard or mouse. As such, we can safely ignore these
+        // elements.
+      case 'radio':
+        return true;
+    }
+  }
+  return false;
+};
+
+const getShortcutLength = (shortcut: Shortcut): string => {
+  return Object.values(shortcut).filter(key => Boolean(key)).length.toString();
+};
+
+class RecordingClient {
+  static readonly defaultSetupOptions: Readonly<RecordingClientOptions> = Object.freeze({
+    debug: false,
+    allowUntrustedEvents: false,
+    selectorTypesToRecord:
+                             [
+                               'aria',
+                               'css',
+                               'text',
+                               'xpath',
+                               'pierce',
+                             ] as SelectorType[],
+  });
+
+  #computer: SelectorComputer;
+
+  #isTrustedEvent = (event: Event): boolean => event.isTrusted;
+  #stopShortcuts: Shortcut[] = [];
+  #logger: Logger;
+
+  constructor(
+      bindings: AccessibilityBindings,
+      options = RecordingClient.defaultSetupOptions,
+  ) {
+    this.#logger = new Logger(options.debug ? 'debug' : 'silent');
+    this.#logger.log('creating a RecordingClient');
+    this.#computer = new SelectorComputer(
+        bindings,
+        this.#logger,
+        options.selectorAttribute,
+        options.selectorTypesToRecord,
+    );
+
+    if (options.allowUntrustedEvents) {
+      this.#isTrustedEvent = (): boolean => true;
+    }
+
+    this.#stopShortcuts = options.stopShortcuts ?? [];
+  }
+
+  start = (): void => {
+    this.#logger.log('Setting up recording listeners');
+
+    window.addEventListener('keydown', this.#onKeyDown, true);
+    window.addEventListener('beforeinput', this.#onBeforeInput, true);
+    window.addEventListener('input', this.#onInput, true);
+    window.addEventListener('keyup', this.#onKeyUp, true);
+
+    window.addEventListener('pointerdown', this.#onPointerDown, true);
+    window.addEventListener('click', this.#onClick, true);
+    window.addEventListener('auxclick', this.#onClick, true);
+
+    window.addEventListener('beforeunload', this.#onBeforeUnload, true);
+  };
+
+  stop = (): void => {
+    this.#logger.log('Tearing down client listeners');
+
+    window.removeEventListener('keydown', this.#onKeyDown, true);
+    window.removeEventListener('beforeinput', this.#onBeforeInput, true);
+    window.removeEventListener('input', this.#onInput, true);
+    window.removeEventListener('keyup', this.#onKeyUp, true);
+
+    window.removeEventListener('pointerdown', this.#onPointerDown, true);
+    window.removeEventListener('click', this.#onClick, true);
+    window.removeEventListener('auxclick', this.#onClick, true);
+
+    window.removeEventListener('beforeunload', this.#onBeforeUnload, true);
+  };
+
+  getSelectors = (node: Node): Selector[] => {
+    return this.#computer.getSelectors(node);
+  };
+
+  getCSSSelector = (node: Node): Selector|undefined => {
+    return this.#computer.getCSSSelector(node);
+  };
+
+  getTextSelector = (node: Node): Selector|undefined => {
+    return this.#computer.getTextSelector(node);
+  };
+
+  queryCSSSelectorAllForTesting = (selector: Selector): Element[] => {
+    return queryCSSSelectorAll(selector);
+  };
+
+  #wasStopShortcutPress = (event: KeyboardEvent): boolean => {
+    for (const shortcut of this.#stopShortcuts ?? []) {
+      if (event.shiftKey === shortcut.shift && event.ctrlKey === shortcut.ctrl && event.metaKey === shortcut.meta &&
+          event.keyCode === shortcut.keyCode) {
+        this.stop();
+        haultImmediateEvent(event);
+        window.stopShortcut(getShortcutLength(shortcut));
+        return true;
+      }
+    }
+    return false;
+  };
+
+  #initialInputTarget: {element: Element, selectors: Selector[]} = {element: document.documentElement, selectors: []};
+
+  /**
+   * Sets the current input target and computes the selector.
+   *
+   * This needs to be called before any input-related events (keydown, keyup,
+   * input, change, etc) occur so the precise selector is known. Since we
+   * capture on the `Window`, it suffices to call this on the first event in any
+   * given input sequence. This will always be either `keydown`, `beforeinput`,
+   * or `input`.
+   */
+  #setInitialInputTarget = (event: Event): void => {
+    const element = event.composedPath()[0];
+    assert(element instanceof Element);
+    if (this.#initialInputTarget.element === element) {
+      return;
+    }
+    this.#initialInputTarget = {element, selectors: this.getSelectors(element)};
+  };
+
+  # KeyboardEvent): void => {
+    if (!this.#isTrustedEvent(event)) {
+      return;
+    }
+    if (this.#wasStopShortcutPress(event)) {
+      return;
+    }
+    this.#setInitialInputTarget(event);
+    this.#addStep({
+      type: 'keyDown' as StepType.KeyDown,
+      key: event.key as Schema.Key,
+    });
+  };
+
+  # Event): void => {
+    if (!this.#isTrustedEvent(event)) {
+      return;
+    }
+    this.#setInitialInputTarget(event);
+  };
+
+  # Event): void => {
+    if (!this.#isTrustedEvent(event)) {
+      return;
+    }
+    this.#setInitialInputTarget(event);
+    if (isIgnorableInputElement(this.#initialInputTarget.element)) {
+      return;
+    }
+    const {element, selectors} = this.#initialInputTarget;
+    this.#addStep({
+      type: 'change' as StepType.Change,
+      selectors,
+      value: 'value' in element ? element.value as string : element.textContent as string,
+    });
+  };
+
+  # KeyboardEvent): void => {
+    if (!this.#isTrustedEvent(event)) {
+      return;
+    }
+    this.#addStep({
+      type: 'keyUp' as StepType.KeyUp,
+      key: event.key as Schema.Key,
+    });
+  };
+
+  #initialPointerTarget: {element: Element, selectors: Selector[]} = {
+    element: document.documentElement,
+    selectors: [],
+  };
+  #setInitialPointerTarget = (event: Event): void => {
+    const element = getClickableTargetFromEvent(event);
+    if (this.#initialPointerTarget.element === element) {
+      return;
+    }
+    this.#initialPointerTarget = {
+      element,
+      selectors: this.#computer.getSelectors(element),
+    };
+  };
+
+  #pointerDownTimestamp = 0;
+  # MouseEvent): void => {
+    if (!this.#isTrustedEvent(event)) {
+      return;
+    }
+    this.#pointerDownTimestamp = event.timeStamp;
+    this.#setInitialPointerTarget(event);
+  };
+
+  # MouseEvent): void => {
+    if (!this.#isTrustedEvent(event)) {
+      return;
+    }
+    this.#setInitialPointerTarget(event);
+    const attributes = createClickAttributes(event, this.#initialPointerTarget.element);
+    if (!attributes) {
+      return;
+    }
+    const duration = event.timeStamp - this.#pointerDownTimestamp;
+    this.#addStep({
+      type: event.detail === 2 ? 'doubleClick' as StepType.DoubleClick : 'click' as StepType.Click,
+      selectors: this.#initialPointerTarget.selectors,
+      duration: duration > 350 ? duration : undefined,
+      ...attributes,
+    });
+  };
+
+  # Event): void => {
+    this.#logger.log('Unloading...');
+    if (!this.#isTrustedEvent(event)) {
+      return;
+    }
+    this.#addStep({type: 'beforeUnload'});
+  };
+
+  #addStep = (step: Step): void => {
+    const payload = JSON.stringify(step);
+    this.#logger.log(`Adding step: ${payload}`);
+    window.addStep(payload);
+  };
+}
+
+export {RecordingClient};
diff --git a/front_end/panels/recorder/injected/SelectorComputer.ts b/front_end/panels/recorder/injected/SelectorComputer.ts
new file mode 100644
index 0000000..d545cc3
--- /dev/null
+++ b/front_end/panels/recorder/injected/SelectorComputer.ts
@@ -0,0 +1,150 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {type SelectorType} from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+import {type Logger} from './Logger.js';
+import {MonotonicArray} from './MonotonicArray.js';
+
+import {
+  computeARIASelector,
+  type AccessibilityBindings,
+} from './selectors/ARIASelector.js';
+import {computeCSSSelector} from './selectors/CSSSelector.js';
+import {computePierceSelector} from './selectors/PierceSelector.js';
+import {type Selector} from './selectors/Selector.js';
+import {computeTextSelector} from './selectors/TextSelector.js';
+import {computeXPath} from './selectors/XPath.js';
+
+const prefixSelector = (
+    selector: Selector|undefined,
+    prefix: string,
+    ): Selector|undefined => {
+  if (selector === undefined) {
+    return;
+  }
+  if (typeof selector === 'string') {
+    return `${prefix}/${selector}`;
+  }
+  return selector.map(selector => `${prefix}/${selector}`);
+};
+
+export class SelectorComputer {
+  #customAttributes = [
+    // Most common attributes first.
+    'data-testid',
+    'data-test',
+    'data-qa',
+    'data-cy',
+    'data-test-id',
+    'data-qa-id',
+    'data-testing',
+  ];
+
+  #bindings: AccessibilityBindings;
+  #logger: Logger;
+  #nodes = new MonotonicArray<Node>();
+  #selectorFunctionsInOrder: Array<(node: Node) => Selector | undefined>;
+
+  constructor(
+      bindings: AccessibilityBindings,
+      logger: Logger,
+      customAttribute = '',
+      selectorTypesToRecord?: SelectorType[],
+  ) {
+    this.#bindings = bindings;
+    this.#logger = logger;
+
+    let selectorOrder = [
+      'aria',
+      'css',
+      'xpath',
+      'pierce',
+      'text',
+    ] as SelectorType[];
+    if (customAttribute) {
+      // Custom DOM attributes indicate a preference for CSS/XPath selectors.
+      this.#customAttributes.unshift(customAttribute);
+      selectorOrder = [
+        'css',
+        'xpath',
+        'pierce',
+        'aria',
+        'text',
+      ] as SelectorType[];
+    }
+
+    this.#selectorFunctionsInOrder = selectorOrder
+                                         .filter(type => {
+                                           if (selectorTypesToRecord) {
+                                             return selectorTypesToRecord.includes(type);
+                                           }
+                                           return true;
+                                         })
+                                         .map(selectorType => {
+                                           switch (selectorType) {
+                                             case 'css':
+                                               return this.getCSSSelector.bind(this);
+                                             case 'xpath':
+                                               return this.getXPathSelector.bind(this);
+                                             case 'pierce':
+                                               return this.getPierceSelector.bind(this);
+                                             case 'aria':
+                                               return this.getARIASelector.bind(this);
+                                             case 'text':
+                                               return this.getTextSelector.bind(this);
+                                             default:
+                                               throw new Error('Unknown selector type: ' + selectorType);
+                                           }
+                                         });
+  }
+
+  getSelectors(node: Node): Selector[] {
+    const selectors: Selector[] = [];
+    for (const getSelector of this.#selectorFunctionsInOrder) {
+      const selector = getSelector(node);
+      if (selector) {
+        selectors.push(selector);
+      }
+    }
+    return selectors;
+  }
+
+  getCSSSelector(node: Node): Selector|undefined {
+    return this.#logger.timed(`getCSSSelector: ${this.#nodes.getOrInsert(node)} ${node.nodeName}`, () => {
+      return computeCSSSelector(node, this.#customAttributes);
+    });
+  }
+
+  getTextSelector(node: Node): Selector|undefined {
+    return this.#logger.timed(`getTextSelector: ${this.#nodes.getOrInsert(node)} ${node.nodeName}`, () => {
+      return prefixSelector(computeTextSelector(node), 'text');
+    });
+  }
+
+  getXPathSelector(node: Node): Selector|undefined {
+    return this.#logger.timed(`getXPathSelector: ${this.#nodes.getOrInsert(node)} ${node.nodeName}`, () => {
+      return prefixSelector(
+          computeXPath(node, true, this.#customAttributes),
+          'xpath',
+      );
+    });
+  }
+
+  getPierceSelector(node: Node): Selector|undefined {
+    return this.#logger.timed(`getPierceSelector: ${this.#nodes.getOrInsert(node)} ${node.nodeName}`, () => {
+      return prefixSelector(
+          computePierceSelector(node, this.#customAttributes),
+          'pierce',
+      );
+    });
+  }
+
+  getARIASelector(node: Node): Selector|undefined {
+    return this.#logger.timed(`getARIASelector: ${this.#nodes.getOrInsert(node)} ${node.nodeName}`, () => {
+      return prefixSelector(computeARIASelector(node, this.#bindings), 'aria');
+    });
+  }
+}
diff --git a/front_end/panels/recorder/injected/SelectorPicker.ts b/front_end/panels/recorder/injected/SelectorPicker.ts
new file mode 100644
index 0000000..ec6c9f3
--- /dev/null
+++ b/front_end/panels/recorder/injected/SelectorPicker.ts
@@ -0,0 +1,63 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {Logger} from './Logger.js';
+import {SelectorComputer} from './SelectorComputer.js';
+import {type AccessibilityBindings} from './selectors/ARIASelector.js';
+import {getMouseEventOffsets, getClickableTargetFromEvent, haultImmediateEvent} from './util.js';
+
+declare global {
+  interface Window {
+    captureSelectors(data: string): void;
+  }
+}
+
+class SelectorPicker {
+  #logger: Logger;
+  #computer: SelectorComputer;
+
+  constructor(
+      bindings: AccessibilityBindings,
+      customAttribute = '',
+      debug = true,
+  ) {
+    this.#logger = new Logger(debug ? 'debug' : 'silent');
+    this.#logger.log('Creating a SelectorPicker');
+    this.#computer = new SelectorComputer(
+        bindings,
+        this.#logger,
+        customAttribute,
+    );
+  }
+
+  #handleClickEvent = (event: MouseEvent): void => {
+    haultImmediateEvent(event);
+
+    const target = getClickableTargetFromEvent(event);
+    window.captureSelectors(
+        JSON.stringify({
+          selectors: this.#computer.getSelectors(target),
+          ...getMouseEventOffsets(event, target),
+        }),
+    );
+  };
+
+  start = (): void => {
+    this.#logger.log('Setting up selector listeners');
+
+    window.addEventListener('click', this.#handleClickEvent, true);
+    window.addEventListener('mousedown', haultImmediateEvent, true);
+    window.addEventListener('mouseup', haultImmediateEvent, true);
+  };
+
+  stop = (): void => {
+    this.#logger.log('Tearing down selector listeners');
+
+    window.removeEventListener('click', this.#handleClickEvent, true);
+    window.removeEventListener('mousedown', haultImmediateEvent, true);
+    window.removeEventListener('mouseup', haultImmediateEvent, true);
+  };
+}
+
+export {SelectorPicker};
diff --git a/front_end/panels/recorder/injected/Step.ts b/front_end/panels/recorder/injected/Step.ts
new file mode 100644
index 0000000..feffac2
--- /dev/null
+++ b/front_end/panels/recorder/injected/Step.ts
@@ -0,0 +1,18 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+import {type Schema} from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+
+export type ClickStep = Schema.ClickStep;
+export type KeyDownStep = Schema.KeyDownStep;
+export type KeyUpStep = Schema.KeyUpStep;
+export type DoubleClickStep = Schema.DoubleClickStep;
+export type ChangeStep = Schema.ChangeStep;
+
+export interface BeforeUnloadStep {
+  type: 'beforeUnload';
+}
+
+export type Step =|KeyDownStep|KeyUpStep|ClickStep|DoubleClickStep|BeforeUnloadStep|ChangeStep;
diff --git a/front_end/panels/recorder/injected/injected.ts b/front_end/panels/recorder/injected/injected.ts
new file mode 100644
index 0000000..df3e46e
--- /dev/null
+++ b/front_end/panels/recorder/injected/injected.ts
@@ -0,0 +1,92 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as RecordingClient from './RecordingClient.js';
+import * as SelectorPicker from './SelectorPicker.js';
+import type * as Step from './Step.js';
+// eslint-disable-next-line rulesdir/es_modules_import
+import {type AccessibilityBindings} from './selectors/ARIASelector.js';
+
+declare global {
+  interface Window {
+    stopShortcut(payload: string): void;
+    // eslint-disable-next-line @typescript-eslint/naming-convention
+    DevToolsRecorder: DevToolsRecorder;
+  }
+}
+
+class DevToolsRecorder {
+  #recordingClient?: RecordingClient.RecordingClient;
+  startRecording(
+      bindings: AccessibilityBindings,
+      options?: RecordingClient.RecordingClientOptions,
+      ): void {
+    if (this.#recordingClient) {
+      throw new Error('Recording client already started.');
+    }
+    if (this.#selectorPicker) {
+      throw new Error('Selector picker is active.');
+    }
+    this.#recordingClient = new RecordingClient.RecordingClient(
+        bindings,
+        options,
+    );
+    this.#recordingClient.start();
+  }
+  stopRecording(): void {
+    if (!this.#recordingClient) {
+      throw new Error('Recording client was not started.');
+    }
+    this.#recordingClient.stop();
+    this.#recordingClient = undefined;
+  }
+
+  get recordingClientForTesting(): RecordingClient.RecordingClient {
+    if (!this.#recordingClient) {
+      throw new Error('Recording client was not started.');
+    }
+    return this.#recordingClient;
+  }
+
+  #selectorPicker?: SelectorPicker.SelectorPicker;
+  startSelectorPicker(
+      bindings: AccessibilityBindings,
+      customAttribute?: string,
+      debug?: boolean,
+      ): void {
+    if (this.#selectorPicker) {
+      throw new Error('Selector picker already started.');
+    }
+    if (this.#recordingClient) {
+      this.#recordingClient.stop();
+    }
+    this.#selectorPicker = new SelectorPicker.SelectorPicker(
+        bindings,
+        customAttribute,
+        debug,
+    );
+    this.#selectorPicker.start();
+  }
+  stopSelectorPicker(): void {
+    if (!this.#selectorPicker) {
+      throw new Error('Selector picker was not started.');
+    }
+    this.#selectorPicker.stop();
+    this.#selectorPicker = undefined;
+    if (this.#recordingClient) {
+      this.#recordingClient.start();
+    }
+  }
+}
+
+if (!window.DevToolsRecorder) {
+  window.DevToolsRecorder = new DevToolsRecorder();
+}
+
+export {
+  type Step,
+  type RecordingClient,
+  type SelectorPicker,
+  type DevToolsRecorder,
+};
diff --git a/front_end/panels/recorder/injected/rollup.config.js b/front_end/panels/recorder/injected/rollup.config.js
new file mode 100644
index 0000000..6c5c73f
--- /dev/null
+++ b/front_end/panels/recorder/injected/rollup.config.js
@@ -0,0 +1,21 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {terser} = require('../../../../node_modules/rollup-plugin-terser/rollup-plugin-terser.js');
+
+/**
+ * Checks if an env variable is true.
+ * @param {string|undefined} envVar
+ * @returns Whether the flag is 'true' or not.
+ */
+function isEnvVarTrue(envVar) {
+  return envVar === 'true';
+}
+
+// eslint-disable-next-line import/no-default-export
+export default {
+  treeshake: false,
+  output: [{format: 'iife'}],
+  plugins: !isEnvVarTrue(process.env.DEBUG_INJECTED) ? [terser()] : []
+};
diff --git a/front_end/panels/recorder/injected/selectors/ARIASelector.ts b/front_end/panels/recorder/injected/selectors/ARIASelector.ts
new file mode 100644
index 0000000..a09fc82
--- /dev/null
+++ b/front_end/panels/recorder/injected/selectors/ARIASelector.ts
@@ -0,0 +1,183 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {type Selector, type DeepSelector} from './Selector.js';
+
+export interface AccessibilityBindings {
+  getAccessibleName(node: Node): string;
+  getAccessibleRole(node: Node): string;
+}
+
+class ARIASelectorComputer {
+  #bindings: AccessibilityBindings;
+
+  constructor(bindings: AccessibilityBindings) {
+    this.#bindings = bindings;
+  }
+
+  // Takes a path consisting of element names and roles and makes sure that
+  // every element resolves to a single result. If it does, the selector is added
+  // to the chain of selectors.
+  #computeUniqueARIASelectorForElements = (
+      elements: {name: string, role: string}[],
+      queryByRoleOnly: boolean,
+      ): DeepSelector|undefined => {
+    const selectors: string[] = [];
+    let parent: Element|Document = document;
+    for (const element of elements) {
+      let result = this.#queryA11yTreeOneByName(parent, element.name);
+      if (result) {
+        selectors.push(element.name);
+        parent = result;
+        continue;
+      }
+      if (queryByRoleOnly) {
+        result = this.#queryA11yTreeOneByRole(parent, element.role);
+        if (result) {
+          selectors.push(`[role="${element.role}"]`);
+          parent = result;
+          continue;
+        }
+      }
+      result = this.#queryA11yTreeOneByNameAndRole(
+          parent,
+          element.name,
+          element.role,
+      );
+      if (result) {
+        selectors.push(`${element.name}[role="${element.role}"]`);
+        parent = result;
+        continue;
+      }
+      return;
+    }
+    return selectors;
+  };
+
+  #queryA11yTreeOneByName = (
+      parent: Element|Document,
+      name?: string,
+      ): Element|null => {
+    if (!name) {
+      return null;
+    }
+    const result = this.#queryA11yTree(parent, name);
+    if (result.length !== 1) {
+      return null;
+    }
+    return result[0];
+  };
+
+  #queryA11yTreeOneByRole = (
+      parent: Element|Document,
+      role?: string,
+      ): Element|null => {
+    if (!role) {
+      return null;
+    }
+    const result = this.#queryA11yTree(parent, undefined, role);
+    if (result.length !== 1) {
+      return null;
+    }
+    return result[0];
+  };
+
+  #queryA11yTreeOneByNameAndRole = (
+      parent: Element|Document,
+      name?: string,
+      role?: string,
+      ): Element|null => {
+    if (!role || !name) {
+      return null;
+    }
+    const result = this.#queryA11yTree(parent, name, role);
+    if (result.length !== 1) {
+      return null;
+    }
+    return result[0];
+  };
+
+  // Queries the DOM tree for elements with matching accessibility name and role.
+  // It attempts to mimic https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/#method-queryAXTree.
+  #queryA11yTree = (
+      parent: Element|Document,
+      name?: string,
+      role?: string,
+      ): Element[] => {
+    const result: Element[] = [];
+    if (!name && !role) {
+      throw new Error('Both role and name are empty');
+    }
+    const shouldMatchName = Boolean(name);
+    const shouldMatchRole = Boolean(role);
+    const collect = (root: Element|ShadowRoot): void => {
+      const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
+      do {
+        const currentNode = iter.currentNode as HTMLElement;
+        if (currentNode.shadowRoot) {
+          collect(currentNode.shadowRoot);
+        }
+        if (currentNode instanceof ShadowRoot) {
+          continue;
+        }
+        if (shouldMatchName && this.#bindings.getAccessibleName(currentNode) !== name) {
+          continue;
+        }
+        if (shouldMatchRole && this.#bindings.getAccessibleRole(currentNode) !== role) {
+          continue;
+        }
+        result.push(currentNode);
+      } while (iter.nextNode());
+    };
+    collect(parent instanceof Document ? document.documentElement : parent);
+    return result;
+  };
+
+  compute = (node: Node): Selector|undefined => {
+    let selector: Selector|undefined;
+    let current: Node|null = node;
+    const elements: {name: string, role: string}[] = [];
+    while (current) {
+      const role = this.#bindings.getAccessibleRole(current);
+      const name = this.#bindings.getAccessibleName(current);
+      if (!role && !name) {
+        if (current === node) {
+          break;
+        }
+      } else {
+        elements.unshift({name, role});
+        selector = this.#computeUniqueARIASelectorForElements(
+            elements,
+            current !== node,
+        );
+        if (selector) {
+          break;
+        }
+        if (current !== node) {
+          elements.shift();
+        }
+      }
+      current = current.parentNode;
+      if (current instanceof ShadowRoot) {
+        current = current.host;
+      }
+    }
+    return selector;
+  };
+}
+
+/**
+ * Computes the ARIA selector for a node.
+ *
+ * @param node - The node to compute.
+ * @returns The computed CSS selector.
+ *
+ * @internal
+ */
+export const computeARIASelector = (
+    node: Node,
+    bindings: AccessibilityBindings,
+    ): Selector|undefined => {
+  return new ARIASelectorComputer(bindings).compute(node);
+};
diff --git a/front_end/panels/recorder/injected/selectors/CSSSelector.ts b/front_end/panels/recorder/injected/selectors/CSSSelector.ts
new file mode 100644
index 0000000..56e6f1a
--- /dev/null
+++ b/front_end/panels/recorder/injected/selectors/CSSSelector.ts
@@ -0,0 +1,311 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {SelectorPart, type Selector} from './Selector.js';
+
+export interface QueryableNode extends Node {
+  querySelectorAll(selectors: string): NodeListOf<Element>;
+}
+
+const idSelector = (id: string): string => {
+  return `#${CSS.escape(id)}`;
+};
+
+const attributeSelector = (name: string, value: string): string => {
+  return `[${name}='${CSS.escape(value)}']`;
+};
+
+const classSelector = (selector: string, className: string): string => {
+  return `${selector}.${CSS.escape(className)}`;
+};
+
+const nthTypeSelector = (selector: string, index: number): string => {
+  return `${selector}:nth-of-type(${index + 1})`;
+};
+
+const typeSelector = (selector: string, type: string): string => {
+  return `${selector}${attributeSelector('type', type)}`;
+};
+
+const hasUniqueId = (node: Element): boolean => {
+  return (Boolean(node.id) && (node.getRootNode() as QueryableNode).querySelectorAll(idSelector(node.id)).length === 1);
+};
+
+const isUniqueAmongTagNames = (
+    node: Element,
+    children: Iterable<Element>,
+    ): boolean => {
+  for (const child of children) {
+    if (child !== node && child.tagName === node.tagName) {
+      return false;
+    }
+  }
+  return true;
+};
+
+const isUniqueAmongInputTypes = (
+    node: HTMLInputElement,
+    children: Iterable<Element>,
+    ): boolean => {
+  for (const child of children) {
+    if (child !== node && child instanceof HTMLInputElement && child.type === node.type) {
+      return false;
+    }
+  }
+  return true;
+};
+
+const getUniqueClassName = (
+    node: Element,
+    children: Iterable<Element>,
+    ): string|undefined => {
+  const classNames = new Set(node.classList);
+  for (const child of children) {
+    if (child !== node) {
+      for (const className of child.classList) {
+        classNames.delete(className);
+      }
+      if (classNames.size === 0) {
+        break;
+      }
+    }
+  }
+  if (classNames.size > 0) {
+    return classNames.values().next().value;
+  }
+  return undefined;
+};
+
+const getTypeIndex = (node: Element, children: Iterable<Element>): number => {
+  let nthTypeIndex = 0;
+  for (const child of children) {
+    if (child === node) {
+      return nthTypeIndex;
+    }
+    if (child.tagName === node.tagName) {
+      ++nthTypeIndex;
+    }
+  }
+  throw new Error('Node not found in children');
+};
+
+export const getSelectorPart = (
+    node: Node,
+    attributes: string[] = [],
+    ): SelectorPart|undefined => {
+  if (!(node instanceof Element)) {
+    return;
+  }
+
+  // Declared attibutes have the greatest priority.
+  for (const attribute of attributes) {
+    const value = node.getAttribute(attribute);
+    if (value) {
+      return new SelectorPart(attributeSelector(attribute, value), true);
+    }
+  }
+
+  // IDs are supposed to be globally unique, so this has second priority.
+  if (hasUniqueId(node)) {
+    return new SelectorPart(idSelector(node.id), true);
+  }
+
+  // All selectors will be prefixed with the tag name starting here.
+  const selector = node.tagName.toLowerCase();
+
+  // These can only appear once in the entire document, so handle this fast.
+  switch (node.tagName) {
+    case 'BODY':
+    case 'HEAD':
+    case 'HTML':
+      return new SelectorPart(selector, true);
+  }
+
+  const parent = node.parentNode;
+  // If the node has no parent, then the node must be detached. We handle this
+  // gracefully.
+  if (!parent) {
+    return new SelectorPart(selector, true);
+  }
+
+  const children = parent.children;
+
+  // Determine if the child has a unique node name among all children.
+  if (isUniqueAmongTagNames(node, children)) {
+    return new SelectorPart(selector, true);
+  }
+
+  // If it's an input, check uniqueness among types.
+  if (node instanceof HTMLInputElement && isUniqueAmongInputTypes(node, children)) {
+    return new SelectorPart(typeSelector(selector, node.type), true);
+  }
+
+  // Determine if the child has a unique class name.
+  const className = getUniqueClassName(node, children);
+  if (className !== undefined) {
+    return new SelectorPart(classSelector(selector, className), true);
+  }
+
+  // Last resort. Just use the nth-type index. A priori, this will always exists.
+  return new SelectorPart(
+      nthTypeSelector(selector, getTypeIndex(node, children)),
+      false,
+  );
+};
+
+/**
+ * This interface represents operations on an ordered range of indices of type
+ * `I`. Implementations must have the following assumptions:
+ *
+ *  1. `self(self(i)) = self(i)`, i.e. `self` must be idempotent.
+ *  2. `inc(i) > i`.
+ *  3. `j >= i` implies `gte(valueOf(j), i)`, i.e. `gte` preserves the order of
+ *     the range.
+ *
+ */
+export interface RangeOps<I, V> {
+  // Returns a suitable version of an index (e.g. ShadowRoot.host instead of
+  // ShadowRoot).
+  self?(index: I): I;
+
+  // Increments the given index by 1.
+  inc(index: I): I;
+
+  // Gets the value at the given index.
+  valueOf(index: I): V;
+
+  // Must preserve `j >= i` if `value === valueOf(j)`.
+  gte(value: V, index: I): boolean;
+}
+
+/**
+ * The goal of this function is to find the smallest index `i` that makes
+ * `gte(valueOf(i), j)` true for all `j` in `[min, max)`. We do not use binary
+ * search because
+ *
+ *  1. We expect the min-max to be concentrated towards the minimum (< 10
+ *     iterations).
+ *  2. We expect `valueOf` to be `O(n)`, so together with (1), the average will
+ *     be around `O(n)` which is significantly faster than binary search in this
+ *     case.
+ */
+export const findMinMax = <I, V>(
+    [min, max]: [I, I],
+    fns: RangeOps<I, V>,
+    ): V => {
+  fns.self ??= (i): I => i;
+
+  let index = fns.inc(min);
+  let value: V;
+  let isMax: boolean;
+  do {
+    value = fns.valueOf(min);
+    isMax = true;
+    while (index !== max) {
+      min = fns.self(index);
+      index = fns.inc(min);
+      if (!fns.gte(value, index)) {
+        isMax = false;
+        break;
+      }
+    }
+  } while (!isMax);
+  return value;
+};
+
+export class SelectorRangeOps implements RangeOps<QueryableNode, string> {
+  // Close chains (using `>`) are stored in inner arrays.
+  #buffer: SelectorPart[][] = [[]];
+  #attributes: string[];
+  #depth = 0;
+
+  constructor(attributes: string[] = []) {
+    this.#attributes = attributes;
+  }
+
+  inc(node: Node): QueryableNode {
+    return node.parentNode ?? (node.getRootNode() as QueryableNode);
+  }
+  valueOf(node: Node): string {
+    const part = getSelectorPart(node, this.#attributes);
+    if (!part) {
+      throw new Error('Node is not an element');
+    }
+    if (this.#depth > 1) {
+      // Implies this selector is for a distant ancestor.
+      this.#buffer.unshift([part]);
+    } else {
+      // Implies this selector is for a parent.
+      this.#buffer[0].unshift(part);
+    }
+    this.#depth = 0;
+    return this.#buffer.map(parts => parts.join(' > ')).join(' ');
+  }
+  gte(selector: string, node: QueryableNode): boolean {
+    ++this.#depth;
+    return node.querySelectorAll(selector).length === 1;
+  }
+}
+
+/**
+ * Computes the CSS selector for a node.
+ *
+ * @param node - The node to compute.
+ * @returns The computed CSS selector.
+ *
+ * @internal
+ */
+export const computeCSSSelector = (
+    node: Node,
+    attributes?: string[],
+    ): Selector|undefined => {
+  const selectors = [];
+
+  // We want to find the minimal selector that is unique within a document. We
+  // are slightly restricted since selectors cannot cross ShadowRoot borders, so
+  // the actual goal is to find the minimal selector that is unique within a
+  // root node. We then need to repeat this for each shadow root.
+  try {
+    let root: Document|ShadowRoot;
+    while (node instanceof Element) {
+      root = node.getRootNode() as Document | ShadowRoot;
+      selectors.unshift(
+          findMinMax(
+              [node as QueryableNode, root],
+              new SelectorRangeOps(attributes),
+              ),
+      );
+      node = root instanceof ShadowRoot ? root.host : root;
+    }
+  } catch {
+    return undefined;
+  }
+
+  return selectors;
+};
+
+export const queryCSSSelectorAll = (selectors: Selector): Element[] => {
+  if (typeof selectors === 'string') {
+    selectors = [selectors];
+  } else if (selectors.length === 0) {
+    return [];
+  }
+  let lists: NodeListOf<Element>[] = [
+    [document.documentElement] as unknown as NodeListOf<Element>,
+  ];
+  do {
+    const selector = selectors.shift() as string;
+    const roots: NodeListOf<Element>[] = [];
+    for (const nodes of lists) {
+      for (const node of nodes) {
+        const list = (node.shadowRoot ?? node).querySelectorAll(selector);
+        if (list.length > 0) {
+          roots.push(list);
+        }
+      }
+    }
+    lists = roots;
+  } while (selectors.length > 0 && lists.length > 0);
+  return lists.flatMap(list => [...list]);
+};
diff --git a/front_end/panels/recorder/injected/selectors/PierceSelector.ts b/front_end/panels/recorder/injected/selectors/PierceSelector.ts
new file mode 100644
index 0000000..ff5ce22
--- /dev/null
+++ b/front_end/panels/recorder/injected/selectors/PierceSelector.ts
@@ -0,0 +1,98 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {type Selector} from './Selector.js';
+
+import {
+  findMinMax,
+  SelectorRangeOps,
+  type RangeOps,
+  type QueryableNode,
+} from './CSSSelector.js';
+import {
+  pierceQuerySelectorAll,
+} from
+    '../../../../third_party/puppeteer/package/lib/esm/puppeteer/injected/PierceQuerySelector.js';
+
+class PierceSelectorRangeOpts implements RangeOps<QueryableNode, string[][]> {
+  #selector: string[][] = [[]];
+  #attributes: string[];
+  #depth = 0;
+
+  constructor(attributes: string[] = []) {
+    this.#attributes = attributes;
+  }
+
+  inc(node: Node): Document|ShadowRoot {
+    return node.getRootNode() as Document | ShadowRoot;
+  }
+  self(node: Document|ShadowRoot): QueryableNode {
+    return node instanceof ShadowRoot ? node.host : node;
+  }
+  valueOf(node: QueryableNode): string[][] {
+    const selector = findMinMax(
+        [node, node.getRootNode()],
+        new SelectorRangeOps(this.#attributes),
+    );
+    if (this.#depth > 1) {
+      this.#selector.unshift([selector]);
+    } else {
+      this.#selector[0].unshift(selector);
+    }
+    this.#depth = 0;
+    return this.#selector;
+  }
+  gte(selector: string[][], node: Node): boolean {
+    ++this.#depth;
+    // Note we use some insider logic here. `valueOf(node)` will always
+    // correspond to `selector.flat().slice(1)`, so it suffices to check
+    // uniqueness for `selector.flat()[0]`.
+    return pierceQuerySelectorAll(node, selector[0][0]).length === 1;
+  }
+}
+
+/**
+ * Computes the pierce CSS selector for a node.
+ *
+ * @param node - The node to compute.
+ * @returns The computed pierce CSS selector.
+ *
+ * @internal
+ */
+export const computePierceSelector = (
+    node: Node,
+    attributes?: string[],
+    ): string[]|undefined => {
+  try {
+    const ops = new PierceSelectorRangeOpts(attributes);
+    return findMinMax([node, document], ops).flat();
+  } catch {
+    return undefined;
+  }
+};
+
+export const queryPierceSelectorAll = (selectors: Selector): Element[] => {
+  if (typeof selectors === 'string') {
+    selectors = [selectors];
+  } else if (selectors.length === 0) {
+    return [];
+  }
+  let lists: Element[][] = [[document.documentElement]];
+  do {
+    const selector = selectors.shift() as string;
+    const roots: Element[][] = [];
+    for (const nodes of lists) {
+      for (const node of nodes) {
+        const list = pierceQuerySelectorAll(node.shadowRoot ?? node, selector);
+        if (list.length > 0) {
+          roots.push(list);
+        }
+      }
+    }
+    lists = roots;
+  } while (selectors.length > 0 && lists.length > 0);
+  return lists.flatMap(list => [...list]);
+};
diff --git a/front_end/panels/recorder/injected/selectors/Selector.ts b/front_end/panels/recorder/injected/selectors/Selector.ts
new file mode 100644
index 0000000..07fc51a
--- /dev/null
+++ b/front_end/panels/recorder/injected/selectors/Selector.ts
@@ -0,0 +1,27 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * Represents a selector that pierces shadow roots. Each selector before the
+ * last one is matches a shadow root for which we pierce through.
+ */
+export type DeepSelector = string[];
+
+/**
+ * Represents a selector.
+ */
+export type Selector = string|DeepSelector;
+
+export class SelectorPart {
+  value: string;
+  optimized: boolean;
+  constructor(value: string, optimized: boolean) {
+    this.value = value;
+    this.optimized = optimized || false;
+  }
+
+  toString(): string {
+    return this.value;
+  }
+}
diff --git a/front_end/panels/recorder/injected/selectors/TextSelector.ts b/front_end/panels/recorder/injected/selectors/TextSelector.ts
new file mode 100644
index 0000000..eebd7d3
--- /dev/null
+++ b/front_end/panels/recorder/injected/selectors/TextSelector.ts
@@ -0,0 +1,85 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+import {
+  createTextContent,
+} from
+    '../../../../third_party/puppeteer/package/lib/esm/puppeteer/injected/TextContent.js';
+import {
+  textQuerySelectorAll,
+} from
+    '../../../../third_party/puppeteer/package/lib/esm/puppeteer/injected/TextQuerySelector.js';
+
+import {type Selector} from './Selector.js';
+
+const MINIMUM_TEXT_LENGTH = 12;
+const MAXIMUM_TEXT_LENGTH = 64;
+
+const collect = <T>(iter: Iterable<T>, max = Infinity): T[] => {
+  const results = [];
+  for (const value of iter) {
+    if (max <= 0) {
+      break;
+    }
+    results.push(value);
+    --max;
+  }
+  return results;
+};
+
+/**
+ * Computes the text selector for a node.
+ *
+ * @param node - The node to compute.
+ * @returns The computed text selector.
+ *
+ * @internal
+ */
+export const computeTextSelector = (node: Node): Selector|undefined => {
+  const content = createTextContent(node).full.trim();
+  if (!content) {
+    return;
+  }
+
+  // If it's short, just return it.
+  if (content.length <= MINIMUM_TEXT_LENGTH) {
+    const elements = collect(textQuerySelectorAll(document, content), 2);
+    if (elements.length !== 1 || elements[0] !== node) {
+      return;
+    }
+    return [content];
+  }
+
+  // If it's too long, it's probably irrelevant.
+  if (content.length > MAXIMUM_TEXT_LENGTH) {
+    return;
+  }
+
+  // We do a binary search for the optimal length of a substring starting at 0.
+  let left = MINIMUM_TEXT_LENGTH;
+  let right = content.length;
+  while (left <= right) {
+    const center = left + ((right - left) >> 2);
+    const elements = collect(
+        textQuerySelectorAll(document, content.slice(0, center)),
+        2,
+    );
+    if (elements.length !== 1 || elements[0] !== node) {
+      left = center + 1;
+    } else {
+      right = center - 1;
+    }
+  }
+
+  // Never matched.
+  if (right === content.length) {
+    return;
+  }
+
+  // We attempt to round the word in the event we are in the middle of a word.
+  const length = right + 1;
+  const remainder = content.slice(length, length + MAXIMUM_TEXT_LENGTH);
+  return [content.slice(0, length + remainder.search(/ |$/))];
+};
diff --git a/front_end/panels/recorder/injected/selectors/XPath.ts b/front_end/panels/recorder/injected/selectors/XPath.ts
new file mode 100644
index 0000000..1996538
--- /dev/null
+++ b/front_end/panels/recorder/injected/selectors/XPath.ts
@@ -0,0 +1,167 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {SelectorPart, type Selector} from './Selector.js';
+
+const attributeSelector = (name: string, value: string): string => {
+  return `//*[@${name}=${JSON.stringify(value)}]`;
+};
+
+const getSelectorPart = (
+    node: Node,
+    optimized?: boolean,
+    attributes: string[] = [],
+    ): SelectorPart|undefined => {
+  let value: string;
+  switch (node.nodeType) {
+    case Node.ELEMENT_NODE:
+      if (!(node instanceof Element)) {
+        return;
+      }
+      if (optimized) {
+        for (const attribute of attributes) {
+          value = node.getAttribute(attribute) ?? '';
+          if (value) {
+            return new SelectorPart(attributeSelector(attribute, value), true);
+          }
+        }
+      }
+      if (node.id) {
+        return new SelectorPart(attributeSelector('id', node.id), true);
+      }
+      value = node.localName;
+      break;
+    case Node.ATTRIBUTE_NODE:
+      value = '@' + node.nodeName;
+      break;
+    case Node.TEXT_NODE:
+    case Node.CDATA_SECTION_NODE:
+      value = 'text()';
+      break;
+    case Node.PROCESSING_INSTRUCTION_NODE:
+      value = 'processing-instruction()';
+      break;
+    case Node.COMMENT_NODE:
+      value = 'comment()';
+      break;
+    case Node.DOCUMENT_NODE:
+      value = '';
+      break;
+    default:
+      value = '';
+      break;
+  }
+
+  const index = getXPathIndexInParent(node);
+  if (index > 0) {
+    value += `[${index}]`;
+  }
+
+  return new SelectorPart(value, node.nodeType === Node.DOCUMENT_NODE);
+};
+
+const getXPathIndexInParent = (node: Node): number => {
+  /**
+   * @returns -1 in case of error, 0 if no siblings matching the same expression,
+   * XPath index among the same expression-matching sibling nodes otherwise.
+   */
+  function areNodesSimilar(left: Node, right: Node): boolean {
+    if (left === right) {
+      return true;
+    }
+
+    if (left instanceof Element && right instanceof Element) {
+      return left.localName === right.localName;
+    }
+
+    if (left.nodeType === right.nodeType) {
+      return true;
+    }
+
+    // XPath treats CDATA as text nodes.
+    const leftType = left.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : left.nodeType;
+    const rightType = right.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : right.nodeType;
+    return leftType === rightType;
+  }
+
+  const children = node.parentNode ? node.parentNode.children : null;
+  if (!children) {
+    return 0;
+  }
+  let hasSameNamedElements;
+  for (let i = 0; i < children.length; ++i) {
+    if (areNodesSimilar(node, children[i]) && children[i] !== node) {
+      hasSameNamedElements = true;
+      break;
+    }
+  }
+  if (!hasSameNamedElements) {
+    return 0;
+  }
+  let ownIndex = 1;  // XPath indices start with 1.
+  for (let i = 0; i < children.length; ++i) {
+    if (areNodesSimilar(node, children[i])) {
+      if (children[i] === node) {
+        return ownIndex;
+      }
+      ++ownIndex;
+    }
+  }
+
+  throw new Error(
+      'This is impossible; a child must be the child of the parent',
+  );
+};
+
+/**
+ * Computes the XPath for a node.
+ *
+ * @param node - The node to compute.
+ * @param optimized - Whether to optimize the XPath for the node. Does not imply
+ * the XPath is shorter; implies the XPath will be highly-scoped to the node.
+ * @returns The computed XPath.
+ *
+ * @internal
+ */
+export const computeXPath = (
+    node: Node,
+    optimized?: boolean,
+    attributes?: string[],
+    ): Selector|undefined => {
+  if (node.nodeType === Node.DOCUMENT_NODE) {
+    return '/';
+  }
+
+  const selectors = [];
+
+  const buffer = [];
+  let contextNode: Node|null = node;
+  while (contextNode !== document && contextNode) {
+    const part = getSelectorPart(contextNode, optimized, attributes);
+    if (!part) {
+      return;
+    }
+    buffer.unshift(part);
+    if (part.optimized) {
+      contextNode = contextNode.getRootNode();
+    } else {
+      contextNode = contextNode.parentNode;
+    }
+    if (contextNode instanceof ShadowRoot) {
+      selectors.unshift((buffer[0].optimized ? '' : '/') + buffer.join('/'));
+      buffer.splice(0, buffer.length);
+      contextNode = contextNode.host;
+    }
+  }
+
+  if (buffer.length) {
+    selectors.unshift((buffer[0].optimized ? '' : '/') + buffer.join('/'));
+  }
+
+  if (!selectors.length) {
+    return;
+  }
+
+  return selectors;
+};
diff --git a/front_end/panels/recorder/injected/util.ts b/front_end/panels/recorder/injected/util.ts
new file mode 100644
index 0000000..25336f5
--- /dev/null
+++ b/front_end/panels/recorder/injected/util.ts
@@ -0,0 +1,70 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+import {type Schema} from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+
+export function assert<Condition>(condition: Condition): asserts condition {
+  if (!condition) {
+    throw new Error('Assertion failed!');
+  }
+}
+
+export const haultImmediateEvent = (event: Event): void => {
+  event.preventDefault();
+  event.stopImmediatePropagation();
+};
+
+export const getMouseEventOffsets = (event: MouseEvent, target: Element): {offsetX: number, offsetY: number} => {
+  const rect = target.getBoundingClientRect();
+  return {offsetX: event.clientX - rect.x, offsetY: event.clientY - rect.y};
+};
+
+/**
+ * @returns the element that emitted the event.
+ */
+export const getClickableTargetFromEvent = (event: Event): Element => {
+  for (const element of event.composedPath()) {
+    if (!(element instanceof Element)) {
+      continue;
+    }
+    // If an element has no width or height, we skip this target.
+    const rect = element.getBoundingClientRect();
+    if (rect.width === 0 || rect.height === 0) {
+      continue;
+    }
+    return element;
+  }
+  throw new Error(`No target is found in event of type ${event.type}`);
+};
+
+export const createClickAttributes = (event: MouseEvent, target: Element): Schema.ClickAttributes|undefined => {
+  let deviceType: 'pen'|'touch'|undefined;
+  if (event instanceof PointerEvent) {
+    switch (event.pointerType) {
+      case 'mouse':
+        // Default device.
+        break;
+      case 'pen':
+      case 'touch':
+        deviceType = event.pointerType;
+        break;
+      default:
+        // Unsupported device type.
+        return;
+    }
+  }
+  const {offsetX, offsetY} = getMouseEventOffsets(event, target);
+  if (offsetX < 0 || offsetY < 0) {
+    // Event comes from outside the viewport. Can happen as a result of a
+    // simulated click (through a keyboard event e.g.).
+    return;
+  }
+  return {
+    button: (['auxiliary', 'secondary', 'back', 'forward'] as const)[event.button - 1],
+    deviceType,
+    offsetX,
+    offsetY,
+  };
+};
diff --git a/front_end/panels/recorder/models/BUILD.gn b/front_end/panels/recorder/models/BUILD.gn
new file mode 100644
index 0000000..795d6bc
--- /dev/null
+++ b/front_end/panels/recorder/models/BUILD.gn
@@ -0,0 +1,66 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_module.gni")
+
+devtools_module("recorder") {
+  sources = [
+    "ConverterIds.ts",
+    "RecorderSettings.ts",
+    "RecorderShortcutHelper.ts",
+    "RecordingPlayer.ts",
+    "RecordingSession.ts",
+    "RecordingSettings.ts",
+    "RecordingStorage.ts",
+    "SDKUtils.ts",
+    "Schema.ts",
+    "SchemaUtils.ts",
+    "ScreenshotStorage.ts",
+    "ScreenshotUtils.ts",
+    "Section.ts",
+    "Tooltip.ts",
+  ]
+
+  deps = [
+    "../../../core/common:bundle",
+    "../../../core/sdk:bundle",
+    "../../../generated:protocol",
+    "../../../models/persistence:bundle",
+    "../../../panels/elements:bundle",
+    "../../../services/puppeteer:bundle",
+    "../../../third_party/puppeteer:bundle",
+    "../../../ui/legacy:bundle",
+    "../../../third_party/puppeteer-replay:bundle",
+    "../injected:bundle",
+    "../util:bundle",
+  ]
+
+  public_deps = [ "../injected:bundled_library" ]
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "models.ts"
+
+  deps = [ ":recorder" ]
+
+  visibility = [
+    ":*",
+    "../*",
+    "../../../../test/e2e/recorder/*",
+    "../../../../test/interactions/panels/recorder/*",
+    "../../../../test/unittests/front_end/helpers",
+    "../../../../test/unittests/front_end/panels/recorder/*",
+    "../../../ui/components/docs/*",
+    "../../../ui/components/docs/recorder_injected/*",
+    "../../entrypoints/main/*",
+    "../../panels/sources/*",
+    "../../recorder/*",
+    "../../recorder/components/*",
+    "../../recorder/controllers/*",
+    "../../recorder/converters/*",
+    "../components/*",
+  ]
+}
diff --git a/front_end/panels/recorder/models/ConverterIds.ts b/front_end/panels/recorder/models/ConverterIds.ts
new file mode 100644
index 0000000..66af970
--- /dev/null
+++ b/front_end/panels/recorder/models/ConverterIds.ts
@@ -0,0 +1,10 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export const enum ConverterIds {
+  JSON = 'json',
+  Puppeteer = 'puppeteer',
+  Replay = '@puppeteer/replay',
+  Lighthouse = 'lighthouse',
+}
diff --git a/front_end/panels/recorder/models/RecorderSettings.ts b/front_end/panels/recorder/models/RecorderSettings.ts
new file mode 100644
index 0000000..e9fc345
--- /dev/null
+++ b/front_end/panels/recorder/models/RecorderSettings.ts
@@ -0,0 +1,116 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../../core/common/common.js';
+import * as i18n from '../../../core/i18n/i18n.js';
+
+import {ConverterIds} from './ConverterIds.js';
+import {PlayRecordingSpeed} from './RecordingPlayer.js';
+import {SelectorType} from './Schema.js';
+
+const UIStrings = {
+  /**
+   * @description This string is used to generate the default name for the create recording form in the Recording panel.
+   * The format is similar to the one used by MacOS to generate names for screenshots. Both {DATE} and {TIME} are localized
+   * using the current locale.
+   *
+   * @example {2022-08-04} DATE
+   * @example {10:32:48} TIME
+   */
+  defaultRecordingName: 'Recording {DATE} at {TIME}',
+};
+
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/models/RecorderSettings.ts',
+    UIStrings,
+);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+export class RecorderSettings {
+  #selectorAttribute = Common.Settings.Settings.instance().createSetting(
+      'recorderSelectorAttribute',
+      '',
+  );
+  #speed = Common.Settings.Settings.instance().createSetting(
+      'recorderPanelReplaySpeed',
+      PlayRecordingSpeed.Normal,
+  );
+  #replayExtension = Common.Settings.Settings.instance().createSetting(
+      'recorderPanelReplayExtension',
+      '',
+  );
+  #selectorTypes = new Map<SelectorType, Common.Settings.Setting<boolean>>();
+  #preferredCopyFormat = Common.Settings.Settings.instance().createSetting<string>(
+      'recorder_preferred_copy_format',
+      ConverterIds.JSON,
+  );
+
+  constructor() {
+    for (const selectorType of Object.values(SelectorType)) {
+      this.#selectorTypes.set(
+          selectorType,
+          Common.Settings.Settings.instance().createSetting(
+              `recorder${selectorType}SelectorEnabled`,
+              true,
+              ),
+      );
+    }
+  }
+
+  get selectorAttribute(): string {
+    return this.#selectorAttribute.get();
+  }
+
+  set selectorAttribute(value: string) {
+    this.#selectorAttribute.set(value);
+  }
+
+  get speed(): PlayRecordingSpeed {
+    return this.#speed.get();
+  }
+
+  set speed(speed: PlayRecordingSpeed) {
+    this.#speed.set(speed);
+  }
+
+  get replayExtension(): string {
+    return this.#replayExtension.get();
+  }
+
+  set replayExtension(replayExtension: string) {
+    this.#replayExtension.set(replayExtension);
+  }
+
+  get defaultTitle(): Common.UIString.LocalizedString {
+    const now = new Date();
+
+    return i18nString(UIStrings.defaultRecordingName, {
+      DATE: now.toLocaleDateString(),
+      TIME: now.toLocaleTimeString(),
+    });
+  }
+
+  get defaultSelectors(): SelectorType[] {
+    return Object.values(SelectorType)
+        .filter(
+            type => this.getSelectorByType(type),
+        );
+  }
+
+  getSelectorByType(type: SelectorType): boolean|undefined {
+    return this.#selectorTypes.get(type)?.get();
+  }
+
+  setSelectorByType(type: SelectorType, value: boolean): void {
+    this.#selectorTypes.get(type)?.set(value);
+  }
+
+  get preferredCopyFormat(): string {
+    return this.#preferredCopyFormat.get();
+  }
+
+  set preferredCopyFormat(value: string) {
+    this.#preferredCopyFormat.set(value);
+  }
+}
diff --git a/front_end/panels/recorder/models/RecorderShortcutHelper.ts b/front_end/panels/recorder/models/RecorderShortcutHelper.ts
new file mode 100644
index 0000000..03e2ed9
--- /dev/null
+++ b/front_end/panels/recorder/models/RecorderShortcutHelper.ts
@@ -0,0 +1,48 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as UI from '../../../ui/legacy/legacy.js';
+
+export class RecorderShortcutHelper {
+  #abortController: AbortController;
+  #timeoutId: NodeJS.Timeout|null = null;
+  #timeout: number;
+
+  constructor(timeout = 200) {
+    this.#timeout = timeout;
+    this.#abortController = new AbortController();
+  }
+
+  #cleanInternals(): void {
+    this.#abortController.abort();
+    if (this.#timeoutId) {
+      clearTimeout(this.#timeoutId);
+    }
+    this.#abortController = new AbortController();
+  }
+
+  #handleCallback(callback: () => void): void {
+    this.#cleanInternals();
+    void callback();
+  }
+
+  handleShortcut(callback: () => void): void {
+    this.#cleanInternals();
+
+    document.addEventListener(
+        'keyup',
+        event => {
+          if (UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event)) {
+            this.#handleCallback(callback);
+          }
+        },
+        {signal: this.#abortController.signal},
+    );
+
+    this.#timeoutId = setTimeout(
+        () => this.#handleCallback(callback),
+        this.#timeout,
+    );
+  }
+}
diff --git a/front_end/panels/recorder/models/RecordingPlayer.ts b/front_end/panels/recorder/models/RecordingPlayer.ts
new file mode 100644
index 0000000..b4ba86d8
--- /dev/null
+++ b/front_end/panels/recorder/models/RecordingPlayer.ts
@@ -0,0 +1,335 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type * as puppeteer from '../../../third_party/puppeteer/puppeteer.js';
+import * as SDK from '../../../core/sdk/sdk.js';
+import * as Common from '../../../core/common/common.js';
+import * as PuppeteerService from '../../../services/puppeteer/puppeteer.js';
+import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+// eslint-disable-next-line rulesdir/es_modules_import
+import type * as Protocol from '../../../generated/protocol.js';
+
+import {type Step, type UserFlow} from './Schema.js';
+
+export const enum PlayRecordingSpeed {
+  Normal = 'normal',
+  Slow = 'slow',
+  VerySlow = 'very_slow',
+  ExtremelySlow = 'extremely_slow',
+}
+
+const speedDelayMap: Record<PlayRecordingSpeed, number> = {
+  [PlayRecordingSpeed.Normal]: 0,
+  [PlayRecordingSpeed.Slow]: 500,
+  [PlayRecordingSpeed.VerySlow]: 1000,
+  [PlayRecordingSpeed.ExtremelySlow]: 2000,
+} as const;
+
+export const enum ReplayResult {
+  Failure = 'Failure',
+  Success = 'Success',
+}
+
+export const defaultTimeout = 5000;  // ms
+
+export function shouldAttachToTarget(
+    mainTargetId: string,
+    targetInfo: Protocol.Target.TargetInfo,
+    ): boolean {
+  // Ignore chrome extensions as we don't support them. This includes DevTools extensions.
+  if (targetInfo.url.startsWith('chrome-extension://')) {
+    return false;
+  }
+  // Allow DevTools-on-DevTools replay.
+  if (targetInfo.url.startsWith('devtools://') && targetInfo.targetId === mainTargetId) {
+    return true;
+  }
+  if (targetInfo.type !== 'page' && targetInfo.type !== 'iframe') {
+    return false;
+  }
+  // TODO only connect to iframes that are related to the main target. This requires refactoring in Puppeteer: https://github.com/puppeteer/puppeteer/issues/3667.
+  return (targetInfo.targetId === mainTargetId || targetInfo.openerId === mainTargetId || targetInfo.type === 'iframe');
+}
+
+function isPageTarget(target: Protocol.Target.TargetInfo): boolean {
+  // Treat DevTools targets as page targets too.
+  return (
+      target.url.startsWith('devtools://') || target.type === 'page' || target.type === 'background_page' ||
+      target.type === 'webview');
+}
+
+export class RecordingPlayer extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
+  #stopPromise: Promise<void>;
+  #resolveStopPromise?: Function;
+  userFlow: UserFlow;
+  speed: PlayRecordingSpeed;
+  timeout: number;
+  breakpointIndexes: Set<number>;
+  steppingOver: boolean = false;
+  aborted = false;
+  abortPromise: Promise<void>;
+  #abortResolveFn?: Function;
+  #runner?: PuppeteerReplay.Runner;
+
+  constructor(
+      userFlow: UserFlow,
+      {
+        speed,
+        breakpointIndexes = new Set(),
+      }: {
+        speed: PlayRecordingSpeed,
+        breakpointIndexes?: Set<number>,
+      },
+  ) {
+    super();
+    this.userFlow = userFlow;
+    this.speed = speed;
+    this.timeout = userFlow.timeout || defaultTimeout;
+    this.breakpointIndexes = breakpointIndexes;
+    this.#stopPromise = new Promise(resolve => {
+      this.#resolveStopPromise = resolve;
+    });
+
+    this.abortPromise = new Promise(resolve => {
+      this.#abortResolveFn = resolve;
+    });
+  }
+
+  #resolveAndRefreshStopPromise(): void {
+    this.#resolveStopPromise?.();
+    this.#stopPromise = new Promise(resolve => {
+      this.#resolveStopPromise = resolve;
+    });
+  }
+
+  static async connectPuppeteer(): Promise<{
+    page: puppeteer.Page,
+    browser: puppeteer.Browser,
+  }> {
+    const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
+    if (!mainTarget) {
+      throw new Error('Could not find main target');
+    }
+    const childTargetManager = mainTarget.model(
+        SDK.ChildTargetManager.ChildTargetManager,
+    );
+    if (!childTargetManager) {
+      throw new Error('Could not get childTargetManager');
+    }
+    const resourceTreeModel = mainTarget.model(
+        SDK.ResourceTreeModel.ResourceTreeModel,
+    );
+    if (!resourceTreeModel) {
+      throw new Error('Could not get resource tree model');
+    }
+    const mainFrame = resourceTreeModel.mainFrame;
+    if (!mainFrame) {
+      throw new Error('Could not find main frame');
+    }
+
+    // Pass an empty message handler because it will be overwritten by puppeteer anyways.
+    const result = await childTargetManager.createParallelConnection(() => {});
+    const connection = result.connection as SDK.Connections.ParallelConnectionInterface;
+
+    const mainTargetId = await childTargetManager.getParentTargetId();
+    const {page, browser, puppeteerConnection} =
+        await PuppeteerService.PuppeteerConnection.PuppeteerConnectionHelper.connectPuppeteerToConnection(
+            {
+              connection,
+              mainFrameId: mainFrame.id,
+              targetInfos: childTargetManager.targetInfos(),
+              targetFilterCallback: shouldAttachToTarget.bind(null, mainTargetId),
+              isPageTargetCallback: isPageTarget,
+            },
+        );
+
+    if (!page) {
+      throw new Error('could not find main page!');
+    }
+
+    browser.on('targetdiscovered', (targetInfo: Protocol.Target.TargetInfo) => {
+      // Pop-ups opened by the main target won't be auto-attached. Therefore,
+      // we need to create a session for them explicitly. We user openedId
+      // and type to classify a target as requiring a session.
+      if (targetInfo.type !== 'page') {
+        return;
+      }
+      if (targetInfo.targetId === mainTargetId) {
+        return;
+      }
+      if (targetInfo.openerId !== mainTargetId) {
+        return;
+      }
+      void puppeteerConnection._createSession(
+          targetInfo,
+          /* emulateAutoAttach= */ true,
+      );
+    });
+
+    return {page, browser};
+  }
+
+  static async disconnectPuppeteer(browser: puppeteer.Browser): Promise<void> {
+    try {
+      const pages = await browser.pages();
+      for (const page of pages) {
+        const client = (page as puppeteer.Page)._client();
+        await client.send('Network.disable');
+        await client.send('Page.disable');
+        await client.send('Log.disable');
+        await client.send('Performance.disable');
+        await client.send('Runtime.disable');
+        await client.send('Emulation.clearDeviceMetricsOverride');
+        await client.send('Emulation.setAutomationOverride', {enabled: false});
+        for (const frame of page.frames()) {
+          const client = frame._client();
+          await client.send('Network.disable');
+          await client.send('Page.disable');
+          await client.send('Log.disable');
+          await client.send('Performance.disable');
+          await client.send('Runtime.disable');
+          await client.send('Emulation.setAutomationOverride', {enabled: false});
+        }
+      }
+      browser.disconnect();
+    } catch (err) {
+      console.error('Error disconnecting Puppeteer', err.message);
+    }
+  }
+
+  async stop(): Promise<void> {
+    await Promise.race([this.#stopPromise, this.abortPromise]);
+  }
+
+  abort(): void {
+    this.aborted = true;
+    this.#abortResolveFn?.();
+    this.#runner?.abort();
+  }
+
+  disposeForTesting(): void {
+    this.#resolveStopPromise?.();
+    this.#abortResolveFn?.();
+  }
+
+  continue(): void {
+    this.steppingOver = false;
+    this.#resolveAndRefreshStopPromise();
+  }
+
+  stepOver(): void {
+    this.steppingOver = true;
+    this.#resolveAndRefreshStopPromise();
+  }
+
+  updateBreakpointIndexes(breakpointIndexes: Set<number>): void {
+    this.breakpointIndexes = breakpointIndexes;
+  }
+
+  async play(): Promise<void> {
+    const {page, browser} = await RecordingPlayer.connectPuppeteer();
+    this.aborted = false;
+
+    const player = this;
+    class ExtensionWithBreak extends PuppeteerReplay.PuppeteerRunnerExtension {
+      readonly #speed: PlayRecordingSpeed;
+
+      constructor(
+          browser: puppeteer.Browser,
+          page: puppeteer.Page,
+          {
+            timeout,
+            speed,
+          }: {
+            timeout: number,
+            speed: PlayRecordingSpeed,
+          },
+      ) {
+        super(browser, page, {timeout});
+        this.#speed = speed;
+      }
+
+      override async beforeEachStep?(step: Step, flow: UserFlow): Promise<void> {
+        let resolver: () => void = () => {};
+        const promise = new Promise<void>(r => {
+          resolver = r;
+        });
+        player.dispatchEventToListeners(Events.Step, {
+          step,
+          resolve: resolver,
+        });
+        await promise;
+        const currentStepIndex = flow.steps.indexOf(step);
+        const shouldStopAtCurrentStep = player.steppingOver || player.breakpointIndexes.has(currentStepIndex);
+        const shouldWaitForSpeed = step.type !== 'setViewport' && step.type !== 'navigate' && !player.aborted;
+        if (shouldStopAtCurrentStep) {
+          player.dispatchEventToListeners(Events.Stop);
+          await player.stop();
+          player.dispatchEventToListeners(Events.Continue);
+        } else if (shouldWaitForSpeed) {
+          await Promise.race([
+            new Promise(
+                resolve => setTimeout(resolve, speedDelayMap[this.#speed]),
+                ),
+            player.abortPromise,
+          ]);
+        }
+      }
+
+      override async runStep(
+          step: PuppeteerReplay.Schema.Step,
+          flow: PuppeteerReplay.Schema.UserFlow,
+          ): Promise<void> {
+        // When replaying on a DevTools target we skip setViewport and navigate steps
+        // because navigation and viewport changes are not supported there.
+        if (page?.url().startsWith('devtools://') && (step.type === 'setViewport' || step.type === 'navigate')) {
+          return;
+        }
+        return await super.runStep(step, flow);
+      }
+    }
+
+    const extension = new ExtensionWithBreak(browser, page, {
+      timeout: this.timeout,
+      speed: this.speed,
+    });
+
+    this.#runner = await PuppeteerReplay.createRunner(this.userFlow, extension);
+    let error: Error|undefined;
+
+    try {
+      await this.#runner.run();
+    } catch (err) {
+      error = err;
+      console.error('Replay error', err.message);
+    } finally {
+      await RecordingPlayer.disconnectPuppeteer(browser);
+    }
+    if (this.aborted) {
+      this.dispatchEventToListeners(Events.Abort);
+    } else if (error) {
+      this.dispatchEventToListeners(Events.Error, error);
+    } else {
+      this.dispatchEventToListeners(Events.Done);
+    }
+  }
+}
+
+export const enum Events {
+  Abort = 'Abort',
+  Done = 'Done',
+  Step = 'Step',
+  Stop = 'Stop',
+  Error = 'Error',
+  Continue = 'Continue',
+}
+
+type EventTypes = {
+  [Events.Abort]: void,
+  [Events.Done]: void,
+  [Events.Step]: {step: Step, resolve: () => void},
+  [Events.Stop]: void,
+  [Events.Continue]: void,
+  [Events.Error]: Error,
+};
diff --git a/front_end/panels/recorder/models/RecordingSession.ts b/front_end/panels/recorder/models/RecordingSession.ts
new file mode 100644
index 0000000..5303246
--- /dev/null
+++ b/front_end/panels/recorder/models/RecordingSession.ts
@@ -0,0 +1,781 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../../core/common/common.js';
+import * as Platform from '../../../core/platform/platform.js';
+import * as SDK from '../../../core/sdk/sdk.js';
+import * as UI from '../../../ui/legacy/legacy.js';
+import * as Util from '../util/util.js';
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import type * as ProtocolProxyApi from '../../../generated/protocol-proxy-api.js';
+// eslint-disable-next-line rulesdir/es_modules_import
+import type * as Protocol from '../../../generated/protocol.js';
+import type * as Injected from '../injected/injected.js';
+
+import {
+  AssertedEventType,
+  StepType,
+  type Key,
+  type ChangeStep,
+  type ClickStep,
+  type DoubleClickStep,
+  type FrameSelector,
+  type KeyDownStep,
+  type KeyUpStep,
+  type NavigationEvent,
+  type SelectorType,
+  type Step,
+  type Target,
+  type UserFlow,
+} from './Schema.js';
+import {areSelectorsEqual, createEmulateNetworkConditionsStep, createViewportStep} from './SchemaUtils.js';
+import {evaluateInAllFrames, getTargetFrameContext} from './SDKUtils.js';
+
+const formatAsJSLiteral = Platform.StringUtilities.formatAsJSLiteral;
+
+type TargetInfoChangedEvent = {
+  type: 'targetInfoChanged',
+  event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>,
+  target: SDK.Target.Target,
+};
+
+type TargerCreatedRecorderEvent = {
+  type: 'targetCreated',
+  event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>,
+  target: SDK.Target.Target,
+};
+
+type TargetClosedRecorderEvent = {
+  type: 'targetClosed',
+  event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetID>,
+  target: SDK.Target.Target,
+};
+
+type BindingCalledRecorderEvent = {
+  type: 'bindingCalled',
+  event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>,
+  target: SDK.Target.Target,
+  frameId: Protocol.Page.FrameId,
+};
+
+type RecorderEvent =
+    |TargetInfoChangedEvent|TargerCreatedRecorderEvent|TargetClosedRecorderEvent|BindingCalledRecorderEvent;
+
+const unrelatedNavigationTypes = new Set([
+  'typed',
+  'address_bar',
+  'auto_bookmark',
+  'auto_subframe',
+  'generated',
+  'auto_toplevel',
+  'reload',
+  'keyword',
+  'keyword_generated',
+]);
+
+interface Shortcut {
+  meta: boolean;
+  ctrl: boolean;
+  shift: boolean;
+  alt: boolean;
+  keyCode: number;
+}
+
+const createShortcuts = (descriptors: number[][]): Shortcut[] => {
+  const shortcuts: Shortcut[] = [];
+  for (const shortcut of descriptors) {
+    for (const key of shortcut) {
+      const shortcutBase = {meta: false, ctrl: false, shift: false, alt: false, keyCode: -1};
+
+      const {keyCode, modifiers} = UI.KeyboardShortcut.KeyboardShortcut.keyCodeAndModifiersFromKey(key);
+
+      shortcutBase.keyCode = keyCode;
+      const modifiersMap = UI.KeyboardShortcut.Modifiers;
+
+      shortcutBase.ctrl = Boolean(modifiers & modifiersMap.Ctrl);
+      shortcutBase.meta = Boolean(modifiers & modifiersMap.Meta);
+      shortcutBase.shift = Boolean(modifiers & modifiersMap.Shift);
+      shortcutBase.shift = Boolean(modifiers & modifiersMap.Alt);
+
+      if (shortcutBase.keyCode !== -1) {
+        shortcuts.push(shortcutBase);
+      }
+    }
+  }
+
+  return shortcuts;
+};
+
+const evaluateInAllTargets =
+    async(worldName: string, targets: SDK.Target.Target[], expression: string): Promise<void> => {
+  await Promise.all(targets.map(target => evaluateInAllFrames(worldName, target, expression)));
+};
+
+const RecorderBinding = Object.freeze({
+  addStep: 'addStep',
+  stopShortcut: 'stopShortcut',
+});
+
+export class RecordingSession extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
+  readonly #target: SDK.Target.Target;
+  readonly #pageAgent: ProtocolProxyApi.PageApi;
+  readonly #targetAgent: ProtocolProxyApi.TargetApi;
+  readonly #networkManager: SDK.NetworkManager.MultitargetNetworkManager;
+  readonly #resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel;
+  readonly #targets = new Map<string, SDK.Target.Target>();
+  readonly #lastNavigationEntryIdByTarget = new Map<string, number>();
+  readonly #lastNavigationHistoryByTarget = new Map<string, Array<number>>();
+  readonly #scriptIdentifiers = new Map<string, Protocol.Page.ScriptIdentifier>();
+  readonly #runtimeEventDescriptors = new Map<
+      SDK.Target.Target, Common.EventTarget.EventDescriptor<SDK.RuntimeModel.EventTypes, SDK.RuntimeModel.Events>[]>();
+  readonly #childTargetEventDescriptors = new Map<
+      SDK.Target.Target,
+      Common.EventTarget.EventDescriptor<SDK.ChildTargetManager.EventTypes, SDK.ChildTargetManager.Events>[]>();
+  readonly #mutex = new Common.Mutex.Mutex();
+
+  #userFlow: UserFlow;
+  #stepsPendingNavigationByTargetId: Map<string, Step> = new Map();
+  #started = false;
+  #selectorTypesToRecord: SelectorType[] = [];
+
+  constructor(target: SDK.Target.Target, opts: {
+    title: string,
+    selectorTypesToRecord: SelectorType[],
+    selectorAttribute?: string,
+  }) {
+    super();
+    this.#target = target;
+    this.#pageAgent = target.pageAgent();
+    this.#targetAgent = target.targetAgent();
+    this.#networkManager = SDK.NetworkManager.MultitargetNetworkManager.instance();
+    const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+    if (!resourceTreeModel) {
+      throw new Error('ResourceTreeModel is missing for the target: ' + target.id());
+    }
+    this.#resourceTreeModel = resourceTreeModel;
+    this.#target = target;
+    this.#userFlow = {title: opts.title, selectorAttribute: opts.selectorAttribute, steps: []};
+    this.#selectorTypesToRecord = opts.selectorTypesToRecord;
+  }
+
+  /**
+   * @returns - A deep copy of the session's current user flow.
+   */
+  cloneUserFlow(): UserFlow {
+    return structuredClone(this.#userFlow);
+  }
+
+  /**
+   * Overwrites the session's current user flow with the given one.
+   *
+   * This method will not dispatch an `recordingupdated` event.
+   */
+  overwriteUserFlow(flow: Readonly<UserFlow>): void {
+    this.#userFlow = structuredClone(flow);
+  }
+
+  async start(): Promise<void> {
+    if (this.#started) {
+      throw new Error('The session has started');
+    }
+    this.#started = true;
+
+    this.#networkManager.addEventListener(
+        SDK.NetworkManager.MultitargetNetworkManager.Events.ConditionsChanged, this.#appendCurrentNetworkStep, this);
+
+    await this.#appendInitialSteps();
+
+    // Focus the target so that events can be captured without additional actions.
+    await this.#pageAgent.invoke_bringToFront();
+
+    await this.#setUpTarget(this.#target);
+  }
+
+  async stop(): Promise<void> {
+    // Wait for any remaining updates.
+    await this.#dispatchRecordingUpdate();
+
+    // Create a deadlock for the remaining events.
+    void this.#mutex.acquire();
+
+    await Promise.all([...this.#targets.values()].map(this.#tearDownTarget));
+
+    this.#networkManager.removeEventListener(
+        SDK.NetworkManager.MultitargetNetworkManager.Events.ConditionsChanged, this.#appendCurrentNetworkStep, this);
+  }
+
+  async #appendInitialSteps(): Promise<void> {
+    // Quick validation before doing anything.
+    const mainFrame = this.#resourceTreeModel.mainFrame;
+    if (!mainFrame) {
+      throw new Error('Could not find mainFrame.');
+    }
+
+    // Network step.
+    if (this.#networkManager.networkConditions() !== SDK.NetworkManager.NoThrottlingConditions) {
+      this.#appendCurrentNetworkStep();
+    }
+
+    // Viewport step.
+    const {cssLayoutViewport} = await this.#target.pageAgent().invoke_getLayoutMetrics();
+    this.#appendStep(createViewportStep(cssLayoutViewport));
+
+    // Navigation step.
+    const history = await this.#resourceTreeModel.navigationHistory();
+    if (history) {
+      const entry = history.entries[history.currentIndex];
+      this.#lastNavigationEntryIdByTarget.set(this.#target.id(), entry.id);
+      this.#lastNavigationHistoryByTarget.set(this.#target.id(), history.entries.map(entry => entry.id));
+      this.#userFlow.steps.push({
+        type: StepType.Navigate,
+        url: entry.url,
+        assertedEvents: [{type: AssertedEventType.Navigation, url: entry.url, title: entry.title}],
+      });
+    } else {
+      this.#userFlow.steps.push({
+        type: StepType.Navigate,
+        url: mainFrame.url,
+        assertedEvents: [
+          {type: AssertedEventType.Navigation, url: mainFrame.url, title: await this.#getDocumentTitle(this.#target)},
+        ],
+      });
+    }
+
+    // Commit
+    void this.#dispatchRecordingUpdate();
+  }
+
+  async #getDocumentTitle(target: SDK.Target.Target): Promise<string> {
+    const response = await target.runtimeAgent().invoke_evaluate({expression: 'document.title'});
+    return response.result?.value || '';
+  }
+
+  #appendCurrentNetworkStep(): void {
+    const networkConditions = this.#networkManager.networkConditions();
+    this.#appendStep(createEmulateNetworkConditionsStep(networkConditions));
+  }
+
+  #updateTimeout?: number;
+  #updateListeners: Array<() => void> = [];
+  #dispatchRecordingUpdate(): Promise<void> {
+    if (this.#updateTimeout) {
+      clearTimeout(this.#updateTimeout);
+    }
+    this.#updateTimeout = setTimeout(() => {
+                            // Making a copy to prevent mutations of this.userFlow by event consumers.
+                            this.dispatchEventToListeners(Events.RecordingUpdated, structuredClone(this.#userFlow));
+                            this.#updateTimeout = undefined;
+                            for (const resolve of this.#updateListeners) {
+                              resolve();
+                            }
+                            this.#updateListeners.length = 0;
+                          }, 100) as unknown as number;
+    return new Promise<void>(resolve => {
+      this.#updateListeners.push(resolve);
+    });
+  }
+
+  get #previousStep(): Step|undefined {
+    return this.#userFlow.steps.slice(-1)[0];
+  }
+
+  /**
+   * Contains keys that are pressed related to a change step.
+   */
+  #pressedChangeKeys = new Set<Key>();
+
+  /**
+   * Shift-reduces a given step into the user flow.
+   */
+  #appendStep(step: Step): void {
+    switch (step.type) {
+      case 'doubleClick': {
+        for (let j = this.#userFlow.steps.length - 1; j > 0; j--) {
+          const previousStep = this.#userFlow.steps[j];
+          if (previousStep.type === 'click') {
+            step.selectors = previousStep.selectors;
+            this.#userFlow.steps.splice(j, 1);
+            break;
+          }
+        }
+        break;
+      }
+      case 'change': {
+        const previousStep = this.#previousStep;
+        if (!previousStep) {
+          break;
+        }
+        switch (previousStep.type) {
+          // Merging changes.
+          case 'change':
+            if (!areSelectorsEqual(step, previousStep)) {
+              break;
+            }
+            this.#userFlow.steps[this.#userFlow.steps.length - 1] = step;
+            void this.#dispatchRecordingUpdate();
+            return;
+          // Ignore key downs resulting in inputs.
+          case 'keyDown':
+            this.#pressedChangeKeys.add(previousStep.key);
+            this.#userFlow.steps.pop();
+            this.#appendStep(step);
+            return;
+        }
+        break;
+      }
+      case 'keyDown': {
+        // This can happen if there are successive keydown's from a repeated key
+        // for example.
+        if (this.#pressedChangeKeys.has(step.key)) {
+          return;
+        }
+        break;
+      }
+      case 'keyUp': {
+        // Ignore key ups coming from change inputs.
+        if (this.#pressedChangeKeys.has(step.key)) {
+          this.#pressedChangeKeys.delete(step.key);
+          return;
+        }
+        break;
+      }
+    }
+    this.#userFlow.steps.push(step);
+    void this.#dispatchRecordingUpdate();
+  }
+
+  #handleBeforeUnload(context: {frame: FrameSelector, target: Target}, sdkTarget: SDK.Target.Target): void {
+    const lastStep = this.#userFlow.steps[this.#userFlow.steps.length - 1];
+    if (lastStep && !lastStep.assertedEvents?.find(event => event.type === AssertedEventType.Navigation)) {
+      const target = context.target || 'main';
+      const frameSelector = (context.frame || []).join(',');
+      const lastStepTarget = lastStep.target || 'main';
+      const lastStepFrameSelector = (('frame' in lastStep ? lastStep.frame : []) || []).join(',');
+      if (target === lastStepTarget && frameSelector === lastStepFrameSelector) {
+        lastStep.assertedEvents = [{type: AssertedEventType.Navigation}];
+        this.#stepsPendingNavigationByTargetId.set(sdkTarget.id(), lastStep);
+        void this.#dispatchRecordingUpdate();
+      }
+    }
+  }
+
+  #replaceUnloadWithNavigation(target: SDK.Target.Target, event: NavigationEvent): void {
+    const stepPendingNavigation = this.#stepsPendingNavigationByTargetId.get(target.id());
+    if (!stepPendingNavigation) {
+      return;
+    }
+    const step = stepPendingNavigation;
+    if (!step.assertedEvents) {
+      return;
+    }
+    const navigationEvent = step.assertedEvents.find(event => event.type === AssertedEventType.Navigation);
+    if (!navigationEvent || navigationEvent.url) {
+      return;
+    }
+    navigationEvent.url = event.url;
+    navigationEvent.title = event.title;
+    void this.#dispatchRecordingUpdate();
+  }
+
+  #handleStopShortcutBinding(event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>): void {
+    const shortcutLength = Number(event.data.payload);
+    // Look for one less step as the last one gets consumed before creating a step
+    for (let index = 0; index < shortcutLength - 1; index++) {
+      this.#userFlow.steps.pop();
+    }
+
+    this.dispatchEventToListeners(Events.RecordingStopped, structuredClone(this.#userFlow));
+  }
+
+  #receiveBindingCalled(
+      target: SDK.Target.Target,
+      event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>): void {
+    switch (event.data.name) {
+      case RecorderBinding.stopShortcut:
+        this.#handleStopShortcutBinding(event);
+        return;
+      case RecorderBinding.addStep:
+        this.#handleAddStepBinding(target, event);
+        return;
+      default:
+        return;
+    }
+  }
+
+  #handleAddStepBinding(
+      target: SDK.Target.Target,
+      event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>): void {
+    const executionContextId = event.data.executionContextId;
+    let frameId: Protocol.Page.FrameId|undefined;
+    const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
+    if (runtimeModel) {
+      for (const context of runtimeModel.executionContexts()) {
+        if (context.id === executionContextId) {
+          frameId = context.frameId;
+          break;
+        }
+      }
+    }
+    if (!frameId) {
+      throw new Error('No execution context found for the binding call + ' + JSON.stringify(event.data));
+    }
+
+    const step = JSON.parse(event.data.payload) as Injected.Step.Step;
+    const resourceTreeModel =
+        target.model(SDK.ResourceTreeModel.ResourceTreeModel) as SDK.ResourceTreeModel.ResourceTreeModel;
+    const frame = resourceTreeModel.frameForId(frameId);
+    if (!frame) {
+      throw new Error('Could not find frame.');
+    }
+
+    const context = getTargetFrameContext(target, frame);
+
+    if (step.type === 'beforeUnload') {
+      this.#handleBeforeUnload(context, target);
+      return;
+    }
+
+    // TODO: type-safe parsing from client steps to internal step format.
+    switch (step.type) {
+      case 'change': {
+        this.#appendStep({
+          type: 'change',
+          value: step.value,
+          selectors: step.selectors,
+          frame: context.frame.length ? context.frame : undefined,
+          target: context.target,
+        } as ChangeStep);
+        break;
+      }
+      case 'doubleClick': {
+        this.#appendStep({
+          type: 'doubleClick',
+          target: context.target,
+          selectors: step.selectors,
+          offsetY: step.offsetY,
+          offsetX: step.offsetX,
+          frame: context.frame.length ? context.frame : undefined,
+          deviceType: step.deviceType,
+          button: step.button,
+        } as DoubleClickStep);
+        break;
+      }
+      case 'click': {
+        this.#appendStep({
+          type: 'click',
+          target: context.target,
+          selectors: step.selectors,
+          offsetY: step.offsetY,
+          offsetX: step.offsetX,
+          frame: context.frame.length ? context.frame : undefined,
+          duration: step.duration,
+          deviceType: step.deviceType,
+          button: step.button,
+        } as ClickStep);
+        break;
+      }
+      case 'keyUp': {
+        this.#appendStep({
+          type: 'keyUp',
+          key: step.key,
+          frame: context.frame.length ? context.frame : undefined,
+          target: context.target,
+        } as KeyUpStep);
+        break;
+      }
+      case 'keyDown': {
+        this.#appendStep({
+          type: 'keyDown',
+          frame: context.frame.length ? context.frame : undefined,
+          target: context.target,
+          key: step.key,
+        } as KeyDownStep);
+        break;
+      }
+      default:
+        throw new Error('Unhandled client event');
+    }
+  }
+
+  #getStopShortcuts(): Shortcut[] {
+    const descriptors = UI.ShortcutRegistry.ShortcutRegistry.instance()
+                            .shortcutsForAction('chrome_recorder.start-recording')
+                            .map(key => key.descriptors.map(press => press.key));
+
+    return createShortcuts(descriptors);
+  }
+
+  static get #allowUntrustedEvents(): boolean {
+    try {
+      // This setting is set during the test to work around the fact that Puppeteer cannot
+      // send trusted change and input events.
+      Common.Settings.Settings.instance().settingForTest('untrustedRecorderEvents');
+      return true;
+    } catch {
+    }
+    return false;
+  }
+
+  #setUpTarget = async(target: SDK.Target.Target): Promise<void> => {
+    if (target.type() !== SDK.Target.Type.Frame) {
+      return;
+    }
+    this.#targets.set(target.id(), target);
+
+    const a11yModel = target.model(SDK.AccessibilityModel.AccessibilityModel);
+    Platform.assertNotNullOrUndefined(a11yModel);
+    await a11yModel.resumeModel();
+
+    await this.#addBindings(target);
+    await this.#injectApplicationScript(target);
+
+    const childTargetManager = target.model(SDK.ChildTargetManager.ChildTargetManager);
+    Platform.assertNotNullOrUndefined(childTargetManager);
+    this.#childTargetEventDescriptors.set(target, [
+      childTargetManager.addEventListener(
+          SDK.ChildTargetManager.Events.TargetCreated, this.#receiveTargetCreated.bind(this, target)),
+      childTargetManager.addEventListener(
+          SDK.ChildTargetManager.Events.TargetDestroyed, this.#receiveTargetClosed.bind(this, target)),
+      childTargetManager.addEventListener(
+          SDK.ChildTargetManager.Events.TargetInfoChanged, this.#receiveTargetInfoChanged.bind(this, target)),
+    ]);
+
+    await Promise.all(childTargetManager.childTargets().map(this.#setUpTarget));
+  };
+
+  #tearDownTarget = async(target: SDK.Target.Target): Promise<void> => {
+    const descriptors = this.#childTargetEventDescriptors.get(target);
+    if (descriptors) {
+      Common.EventTarget.removeEventListeners(descriptors);
+    }
+
+    await this.#injectCleanUpScript(target);
+    await this.#removeBindings(target);
+  };
+
+  async #addBindings(target: SDK.Target.Target): Promise<void> {
+    const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
+    Platform.assertNotNullOrUndefined(runtimeModel);
+
+    this.#runtimeEventDescriptors.set(
+        target, [runtimeModel.addEventListener(
+                    SDK.RuntimeModel.Events.BindingCalled, this.#receiveBindingCalled.bind(this, target))]);
+
+    await Promise.all(
+        Object.values(RecorderBinding)
+            .map(name => runtimeModel.addBinding({name, executionContextName: Util.DEVTOOLS_RECORDER_WORLD_NAME})));
+  }
+
+  async #removeBindings(target: SDK.Target.Target): Promise<void> {
+    await Promise.all(Object.values(RecorderBinding).map(name => target.runtimeAgent().invoke_removeBinding({name})));
+
+    const descriptors = this.#runtimeEventDescriptors.get(target);
+    if (descriptors) {
+      Common.EventTarget.removeEventListeners(descriptors);
+    }
+  }
+
+  async #injectApplicationScript(target: SDK.Target.Target): Promise<void> {
+    const injectedScript = await Util.InjectedScript.get();
+    const script = `
+      ${injectedScript};DevToolsRecorder.startRecording({getAccessibleName, getAccessibleRole}, {
+        debug: ${Util.isDebugBuild},
+        allowUntrustedEvents: ${RecordingSession.#allowUntrustedEvents},
+        selectorTypesToRecord: ${JSON.stringify(this.#selectorTypesToRecord)},
+        selectorAttribute: ${
+        this.#userFlow.selectorAttribute ? formatAsJSLiteral(this.#userFlow.selectorAttribute) : undefined},
+        stopShortcuts: ${JSON.stringify(this.#getStopShortcuts())},
+      });
+    `;
+    const [{identifier}] = await Promise.all([
+      target.pageAgent().invoke_addScriptToEvaluateOnNewDocument(
+          {source: script, worldName: Util.DEVTOOLS_RECORDER_WORLD_NAME, includeCommandLineAPI: true}),
+      evaluateInAllFrames(Util.DEVTOOLS_RECORDER_WORLD_NAME, target, script),
+    ]);
+    this.#scriptIdentifiers.set(target.id(), identifier);
+  }
+
+  async #injectCleanUpScript(target: SDK.Target.Target): Promise<void> {
+    const scriptId = this.#scriptIdentifiers.get(target.id());
+    if (!scriptId) {
+      return;
+    }
+    await target.pageAgent().invoke_removeScriptToEvaluateOnNewDocument({identifier: scriptId});
+    await evaluateInAllTargets(
+        Util.DEVTOOLS_RECORDER_WORLD_NAME, [...this.#targets.values()], 'DevToolsRecorder.stopRecording()');
+  }
+
+  #receiveTargetCreated(
+      target: SDK.Target.Target, event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>): void {
+    void this.#handleEvent({type: 'targetCreated', event, target});
+  }
+
+  #receiveTargetClosed(
+      eventTarget: SDK.Target.Target, event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetID>): void {
+    // TODO(alexrudenko): target here appears to be the parent target of the target that is closed.
+    // Therefore, we need to find the real target via the targets map.
+    const childTarget = this.#targets.get(event.data);
+    if (childTarget) {
+      void this.#handleEvent({type: 'targetClosed', event, target: childTarget});
+    }
+  }
+
+  #receiveTargetInfoChanged(
+      eventTarget: SDK.Target.Target, event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>): void {
+    const target = this.#targets.get(event.data.targetId) || eventTarget;
+    void this.#handleEvent({type: 'targetInfoChanged', event, target});
+  }
+
+  #handleEvent(event: RecorderEvent): Promise<void> {
+    return this.#mutex.run(async () => {
+      try {
+        if (Util.isDebugBuild) {
+          console.time(`Processing ${JSON.stringify(event)}`);
+        }
+        switch (event.type) {
+          case 'targetClosed':
+            await this.#handleTargetClosed(event);
+            break;
+          case 'targetCreated':
+            await this.#handleTargetCreated(event);
+            break;
+          case 'targetInfoChanged':
+            await this.#handleTargetInfoChanged(event);
+            break;
+        }
+        if (Util.isDebugBuild) {
+          console.timeEnd(`Processing ${JSON.stringify(event)}`);
+        }
+      } catch (err) {
+        console.error('Error happened while processing recording events: ', err.message, err.stack);
+      }
+    });
+  }
+
+  async #handleTargetCreated(event: TargerCreatedRecorderEvent): Promise<void> {
+    if (event.event.data.type !== 'page' && event.event.data.type !== 'iframe') {
+      return;
+    }
+
+    await this.#targetAgent.invoke_attachToTarget({targetId: event.event.data.targetId, flatten: true});
+    const target = SDK.TargetManager.TargetManager.instance().targets().find(t => t.id() === event.event.data.targetId);
+
+    if (!target) {
+      throw new Error('Could not find target.');
+    }
+    // Generally an new target implies all other targets are not waiting for something special in their event buffers, so we flush them here.
+    await this.#setUpTarget(target);
+    // Emitted for e2e tests.
+    window.dispatchEvent(new Event('recorderAttachedToTarget'));
+  }
+
+  async #handleTargetClosed(event: TargetClosedRecorderEvent): Promise<void> {
+    const stepPendingNavigation = this.#stepsPendingNavigationByTargetId.get(event.target.id());
+    if (stepPendingNavigation) {
+      delete stepPendingNavigation.assertedEvents;
+      this.#stepsPendingNavigationByTargetId.delete(event.target.id());
+    }
+    // TODO(alexrudenko): figure out how this works with sections
+    // TODO(alexrudenko): Ignore close events as they only matter for popups but cause more trouble than benefits
+    // const closeStep: CloseStep = {
+    //   type: 'close',
+    //   target: getTargetName(event.target),
+    // };
+    // this.appendStep(closeStep);
+  }
+
+  async #handlePageNavigation(resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel, target: SDK.Target.Target):
+      Promise<boolean> {
+    const history = await resourceTreeModel.navigationHistory();
+    if (!history) {
+      return false;
+    }
+    const entry = history.entries[history.currentIndex];
+    const prevId = this.#lastNavigationEntryIdByTarget.get(target.id());
+    if (prevId === entry.id) {
+      return true;
+    }
+    this.#lastNavigationEntryIdByTarget.set(target.id(), entry.id);
+    const lastHistory = this.#lastNavigationHistoryByTarget.get(target.id()) || [];
+    this.#lastNavigationHistoryByTarget.set(target.id(), history.entries.map(entry => entry.id));
+    if (unrelatedNavigationTypes.has(entry.transitionType) || lastHistory.includes(entry.id)) {
+      const stepPendingNavigation = this.#stepsPendingNavigationByTargetId.get(target.id());
+      if (stepPendingNavigation) {
+        delete stepPendingNavigation.assertedEvents;
+        this.#stepsPendingNavigationByTargetId.delete(target.id());
+      }
+      this.#appendStep({
+        type: StepType.Navigate,
+        url: entry.url,
+        assertedEvents: [{type: AssertedEventType.Navigation, url: entry.url, title: entry.title}],
+      });
+    } else {
+      this.#replaceUnloadWithNavigation(
+          target, {type: AssertedEventType.Navigation, url: entry.url, title: entry.title});
+    }
+    return true;
+  }
+
+  async #handleTargetInfoChanged(event: TargetInfoChangedEvent): Promise<void> {
+    if (event.event.data.type !== 'page' && event.event.data.type !== 'iframe') {
+      return;
+    }
+
+    const target = event.target;
+    const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
+    if (!resourceTreeModel) {
+      throw new Error('ResourceTreeModel is missing in handleNavigation');
+    }
+
+    if (event.event.data.type === 'iframe') {
+      this.#replaceUnloadWithNavigation(
+          target,
+          {type: AssertedEventType.Navigation, url: event.event.data.url, title: await this.#getDocumentTitle(target)});
+    } else if (event.event.data.type === 'page') {
+      if (await this.#handlePageNavigation(resourceTreeModel, target)) {
+        return;
+      }
+      // Needed for #getDocumentTitle to return something meaningful.
+      await this.#waitForDOMContentLoadedWithTimeout(resourceTreeModel, 500);
+      this.#replaceUnloadWithNavigation(
+          target,
+          {type: AssertedEventType.Navigation, url: event.event.data.url, title: await this.#getDocumentTitle(target)});
+    }
+  }
+
+  async #waitForDOMContentLoadedWithTimeout(
+      resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel, timeout: number): Promise<void> {
+    let resolver: (value: void|Promise<void>) => void = () => Promise.resolve();
+    const contentLoadedPromise = new Promise<void>(resolve => {
+      resolver = resolve;
+    });
+    const  void => {
+      resourceTreeModel.removeEventListener(SDK.ResourceTreeModel.Events.DOMContentLoaded, onDomContentLoaded);
+      resolver();
+    };
+    resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.DOMContentLoaded, onDomContentLoaded);
+    await Promise.any([
+      contentLoadedPromise,
+      new Promise<void>(
+          resolve => setTimeout(
+              () => {
+                resourceTreeModel.removeEventListener(
+                    SDK.ResourceTreeModel.Events.DOMContentLoaded, onDomContentLoaded);
+                resolve();
+              },
+              timeout)),
+    ]);
+  }
+}
+
+export const enum Events {
+  RecordingUpdated = 'recordingupdated',
+  RecordingStopped = 'recordingstopped',
+}
+
+type EventTypes = {
+  [Events.RecordingUpdated]: UserFlow,
+  [Events.RecordingStopped]: UserFlow,
+};
diff --git a/front_end/panels/recorder/models/RecordingSettings.ts b/front_end/panels/recorder/models/RecordingSettings.ts
new file mode 100644
index 0000000..46e837c
--- /dev/null
+++ b/front_end/panels/recorder/models/RecordingSettings.ts
@@ -0,0 +1,17 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {
+  type SetViewportStep,
+  type EmulateNetworkConditionsStep,
+} from './Schema.js';
+
+export interface RecordingSettings {
+  viewportSettings?: SetViewportStep;
+  networkConditionsSettings?: EmulateNetworkConditionsStep&{
+    title?: string,
+    i18nTitleKey?: string,
+  };
+  timeout?: number;
+}
diff --git a/front_end/panels/recorder/models/RecordingStorage.ts b/front_end/panels/recorder/models/RecordingStorage.ts
new file mode 100644
index 0000000..6d83a0e
--- /dev/null
+++ b/front_end/panels/recorder/models/RecordingStorage.ts
@@ -0,0 +1,112 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../../core/common/common.js';
+
+import {type UserFlow} from './Schema.js';
+
+let instance: RecordingStorage|null = null;
+
+interface IdGenerator {
+  next(): string;
+}
+
+class UUIDGenerator implements IdGenerator {
+  next(): string {
+    // @ts-ignore
+    return crypto.randomUUID();
+  }
+}
+
+export class RecordingStorage {
+  #recordingsSetting: Common.Settings.Setting<StoredRecording[]>;
+  #mutex = new Common.Mutex.Mutex();
+  #idGenerator: IdGenerator = new UUIDGenerator();
+
+  constructor() {
+    this.#recordingsSetting = Common.Settings.Settings.instance().createSetting(
+        'recorder_recordings_ng',
+        [],
+    );
+  }
+
+  clearForTest(): void {
+    this.#recordingsSetting.set([]);
+    this.#idGenerator = new UUIDGenerator();
+  }
+
+  setIdGeneratorForTest(idGenerator: IdGenerator): void {
+    this.#idGenerator = idGenerator;
+  }
+
+  async saveRecording(flow: UserFlow): Promise<StoredRecording> {
+    const release = await this.#mutex.acquire();
+    try {
+      const recordings = await this.#recordingsSetting.forceGet();
+      const storageName = this.#idGenerator.next();
+      const recording = {storageName, flow};
+      recordings.push(recording);
+      this.#recordingsSetting.set(recordings);
+      return recording;
+    } finally {
+      release();
+    }
+  }
+
+  async updateRecording(
+      storageName: string,
+      flow: UserFlow,
+      ): Promise<StoredRecording> {
+    const release = await this.#mutex.acquire();
+    try {
+      const recordings = await this.#recordingsSetting.forceGet();
+      const recording = recordings.find(
+          recording => recording.storageName === storageName,
+      );
+      if (!recording) {
+        throw new Error('No recording is found during updateRecording');
+      }
+      recording.flow = flow;
+      this.#recordingsSetting.set(recordings);
+      return recording;
+    } finally {
+      release();
+    }
+  }
+
+  async deleteRecording(storageName: string): Promise<void> {
+    const release = await this.#mutex.acquire();
+    try {
+      const recordings = await this.#recordingsSetting.forceGet();
+      this.#recordingsSetting.set(
+          recordings.filter(recording => recording.storageName !== storageName),
+      );
+    } finally {
+      release();
+    }
+  }
+
+  getRecording(storageName: string): StoredRecording|undefined {
+    const recordings = this.#recordingsSetting.get();
+    return recordings.find(
+        recording => recording.storageName === storageName,
+    );
+  }
+
+  getRecordings(): StoredRecording[] {
+    return this.#recordingsSetting.get();
+  }
+
+  static instance(): RecordingStorage {
+    if (!instance) {
+      instance = new RecordingStorage();
+    }
+    return instance;
+  }
+}
+
+export interface StoredRecording {
+  storageName: string;
+  flow: UserFlow;
+}
diff --git a/front_end/panels/recorder/models/SDKUtils.ts b/front_end/panels/recorder/models/SDKUtils.ts
new file mode 100644
index 0000000..dbe687a
--- /dev/null
+++ b/front_end/panels/recorder/models/SDKUtils.ts
@@ -0,0 +1,117 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import type * as Protocol from '../../../generated/protocol.js';
+import * as SDK from '../../../core/sdk/sdk.js';
+import {type Target, type FrameSelector} from './Schema.js';
+
+interface Context {
+  target: Target;
+  frame: FrameSelector;
+}
+
+export function getTargetName(target: SDK.Target.Target): string {
+  if (SDK.TargetManager.TargetManager.instance().primaryPageTarget() === target) {
+    return 'main';
+  }
+  return target.id() === 'main' ? 'main' : target.inspectedURL();
+}
+
+/**
+ * Returns the context for an SDK target and frame.
+ * The frame is identified by the path in the resource tree model.
+ * And the target is identified by `getTargetName`.
+ */
+export function getTargetFrameContext(
+    target: SDK.Target.Target,
+    frame: SDK.ResourceTreeModel.ResourceTreeFrame,
+    ): Context {
+  const path = [];
+  while (frame) {
+    const parentFrame = frame.sameTargetParentFrame();
+    if (!parentFrame) {
+      break;
+    }
+    const childFrames = parentFrame.childFrames;
+    const index = childFrames.indexOf(frame);
+    path.unshift(index);
+    frame = parentFrame;
+  }
+
+  return {target: getTargetName(target), frame: path};
+}
+
+export async function evaluateInAllFrames(
+    worldName: string,
+    target: SDK.Target.Target,
+    expression: string,
+    ): Promise<void> {
+  const runtimeModel = target.model(
+                           SDK.RuntimeModel.RuntimeModel,
+                           ) as SDK.RuntimeModel.RuntimeModel;
+  const executionContexts = runtimeModel.executionContexts();
+
+  const resourceTreeModel = target.model(
+                                SDK.ResourceTreeModel.ResourceTreeModel,
+                                ) as SDK.ResourceTreeModel.ResourceTreeModel;
+  for (const frame of resourceTreeModel.frames()) {
+    const executionContext = executionContexts.find(
+        context => context.frameId === frame.id,
+    );
+    if (!executionContext) {
+      continue;
+    }
+
+    // Note: it would return previously created world if it exists for the frame.
+    const {executionContextId} = await target.pageAgent().invoke_createIsolatedWorld({frameId: frame.id, worldName});
+    await target.runtimeAgent().invoke_evaluate({
+      expression,
+      includeCommandLineAPI: true,
+      contextId: executionContextId,
+    });
+  }
+}
+
+export function findTargetByExecutionContext(
+    targets: Iterable<SDK.Target.Target>,
+    executionContextId: number,
+    ): SDK.Target.Target|undefined {
+  for (const target of targets) {
+    const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
+    if (!runtimeModel) {
+      continue;
+    }
+    for (const context of runtimeModel.executionContexts()) {
+      if (context.id === executionContextId) {
+        return target;
+      }
+    }
+  }
+  return;
+}
+
+export function findFrameIdByExecutionContext(
+    targets: Iterable<SDK.Target.Target>,
+    executionContextId: number,
+    ): Protocol.Page.FrameId|undefined {
+  for (const target of targets) {
+    const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
+    if (!runtimeModel) {
+      continue;
+    }
+    for (const context of runtimeModel.executionContexts()) {
+      if (context.id === executionContextId && context.frameId !== undefined) {
+        return context.frameId;
+      }
+    }
+  }
+  return;
+}
+
+export const isFrameTargetInfo = (
+    target: Protocol.Target.TargetInfo,
+    ): boolean => {
+  return target.type === 'page' || target.type === 'iframe';
+};
diff --git a/front_end/panels/recorder/models/Schema.ts b/front_end/panels/recorder/models/Schema.ts
new file mode 100644
index 0000000..665e1ed
--- /dev/null
+++ b/front_end/panels/recorder/models/Schema.ts
@@ -0,0 +1,35 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {
+  type Schema,
+  StepType,
+  AssertedEventType,
+  SelectorType,
+} from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+
+export type UserFlow = Schema.UserFlow;
+export type Step = Schema.Step;
+export type NavigationEvent = Schema.NavigationEvent;
+export type Target = Schema.Target;
+export type FrameSelector = Schema.FrameSelector;
+export type ChangeStep = Schema.ChangeStep;
+export type ClickStep = Schema.ClickStep;
+export type DoubleClickStep = Schema.DoubleClickStep;
+export type HoverStep = Schema.HoverStep;
+export type KeyDownStep = Schema.KeyDownStep;
+export type KeyUpStep = Schema.KeyUpStep;
+export type SetViewportStep = Schema.SetViewportStep;
+export type EmulateNetworkConditionsStep = Schema.EmulateNetworkConditionsStep;
+export type Selector = Schema.Selector;
+export type StepWithSelectors = Schema.StepWithSelectors;
+export type AssertedEvent = Schema.AssertedEvent;
+export type NavigateStep = Schema.NavigateStep;
+export type ScrollStep = Schema.ScrollStep;
+export type AssertionStep = Schema.AssertionStep;
+export type ClickAttributes = Schema.ClickAttributes;
+export type Key = Schema.Key;
+export {AssertedEventType, StepType, SelectorType};
diff --git a/front_end/panels/recorder/models/SchemaUtils.ts b/front_end/panels/recorder/models/SchemaUtils.ts
new file mode 100644
index 0000000..14b6d73
--- /dev/null
+++ b/front_end/panels/recorder/models/SchemaUtils.ts
@@ -0,0 +1,47 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {
+  type Step,
+  type SetViewportStep,
+  type EmulateNetworkConditionsStep,
+  StepType,
+} from './Schema.js';
+import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
+
+export function createViewportStep(viewport: {
+  clientWidth: number,
+  clientHeight: number,
+}): SetViewportStep {
+  return {
+    type: StepType.SetViewport,
+    width: viewport.clientWidth,
+    height: viewport.clientHeight,
+    // TODO read real parameters here
+    deviceScaleFactor: 1,
+    isMobile: false,
+    hasTouch: false,
+    isLandscape: false,
+  };
+}
+
+export function createEmulateNetworkConditionsStep(conditions: {
+  download: number,
+  upload: number,
+  latency: number,
+}): EmulateNetworkConditionsStep {
+  return {type: StepType.EmulateNetworkConditions, ...conditions};
+}
+
+export function areSelectorsEqual(stepA: Step, stepB: Step): boolean {
+  if ('selectors' in stepA && 'selectors' in stepB) {
+    return JSON.stringify(stepA.selectors) === JSON.stringify(stepB.selectors);
+  }
+  return !('selectors' in stepA) && !('selectors' in stepB);
+}
+
+export const minTimeout = 1;
+export const maxTimeout = 30000;
+export const parse = PuppeteerReplay.parse;
+export const parseStep = PuppeteerReplay.parseStep;
diff --git a/front_end/panels/recorder/models/ScreenshotStorage.ts b/front_end/panels/recorder/models/ScreenshotStorage.ts
new file mode 100644
index 0000000..a9e95f8
--- /dev/null
+++ b/front_end/panels/recorder/models/ScreenshotStorage.ts
@@ -0,0 +1,140 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../../core/common/common.js';
+
+export type Screenshot = string&{_brand: 'ImageData'};
+
+export interface ScreenshotMetaData {
+  recordingName: string;
+  index: number;
+  data: Screenshot;
+}
+
+let instance: ScreenshotStorage|null = null;
+
+// Default size of storage
+const DEFAULT_MAX_STORAGE_SIZE = 50 * 1024 * 1024;
+
+/**
+ * This class stores the screenshots taken for a specific recording
+ * in a settings object. The total storage size is limited to 50 MB
+ * by default and the least recently accessed screenshots will be
+ * deleted first.
+ */
+export class ScreenshotStorage {
+  #screenshotSettings: Common.Settings.Setting<ScreenshotMetaData[]>;
+  #screenshots: Map<string, ScreenshotMetaData>;
+  #maxStorageSize: number;
+
+  constructor(maxStorageSize = DEFAULT_MAX_STORAGE_SIZE) {
+    this.#screenshotSettings = Common.Settings.Settings.instance().createSetting(
+        'recorder_screenshots',
+        [],
+    );
+    this.#screenshots = this.#loadFromSettings();
+    this.#maxStorageSize = maxStorageSize;
+  }
+
+  clear(): void {
+    this.#screenshotSettings.set([]);
+    this.#screenshots = new Map();
+  }
+
+  getScreenshotForSection(
+      recordingName: string,
+      index: number,
+      ): Screenshot|null {
+    const screenshot = this.#screenshots.get(
+        this.#calculateKey(recordingName, index),
+    );
+    if (!screenshot) {
+      return null;
+    }
+
+    this.#syncWithSettings(screenshot);
+    return screenshot.data;
+  }
+
+  storeScreenshotForSection(
+      recordingName: string,
+      index: number,
+      data: Screenshot,
+      ): void {
+    const screenshot = {recordingName, index, data};
+    this.#screenshots.set(this.#calculateKey(recordingName, index), screenshot);
+    this.#syncWithSettings(screenshot);
+  }
+
+  deleteScreenshotsForRecording(recordingName: string): void {
+    for (const [key, entry] of this.#screenshots) {
+      if (entry.recordingName === recordingName) {
+        this.#screenshots.delete(key);
+      }
+    }
+
+    this.#syncWithSettings();
+  }
+
+  #calculateKey(recordingName: string, index: number): string {
+    return `${recordingName}:${index}`;
+  }
+
+  #loadFromSettings(): Map<string, ScreenshotMetaData> {
+    const screenshots = new Map<string, ScreenshotMetaData>();
+    const data = this.#screenshotSettings.get();
+    for (const item of data) {
+      screenshots.set(this.#calculateKey(item.recordingName, item.index), item);
+    }
+
+    return screenshots;
+  }
+
+  #syncWithSettings(modifiedScreenshot?: ScreenshotMetaData): void {
+    if (modifiedScreenshot) {
+      const key = this.#calculateKey(
+          modifiedScreenshot.recordingName,
+          modifiedScreenshot.index,
+      );
+
+      // Make sure that the modified screenshot is moved to the end of the map
+      // as the JS Map remembers the original insertion order of the keys.
+      this.#screenshots.delete(key);
+      this.#screenshots.set(key, modifiedScreenshot);
+    }
+
+    const screenshots = [];
+    let currentStorageSize = 0;
+
+    // Take screenshots from the end of the list until the size constraint is met.
+    for (const [key, screenshot] of Array
+             .from(
+                 this.#screenshots.entries(),
+                 )
+             .reverse()) {
+      if (currentStorageSize < this.#maxStorageSize) {
+        currentStorageSize += screenshot.data.length;
+        screenshots.push(screenshot);
+      } else {
+        // Delete all screenshots that exceed the storage limit.
+        this.#screenshots.delete(key);
+      }
+    }
+
+    this.#screenshotSettings.set(screenshots.reverse());
+  }
+
+  static instance(
+      opts: {
+        forceNew?: boolean|null,
+        maxStorageSize?: number,
+      } = {forceNew: null, maxStorageSize: DEFAULT_MAX_STORAGE_SIZE},
+      ): ScreenshotStorage {
+    const {forceNew, maxStorageSize} = opts;
+    if (!instance || forceNew) {
+      instance = new ScreenshotStorage(maxStorageSize);
+    }
+    return instance;
+  }
+}
diff --git a/front_end/panels/recorder/models/ScreenshotUtils.ts b/front_end/panels/recorder/models/ScreenshotUtils.ts
new file mode 100644
index 0000000..8eeae89
--- /dev/null
+++ b/front_end/panels/recorder/models/ScreenshotUtils.ts
@@ -0,0 +1,53 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as SDK from '../../../core/sdk/sdk.js';
+
+import {type Screenshot} from './ScreenshotStorage.js';
+
+const SCREENSHOT_WIDTH = 160;       // px
+const SCREENSHOT_MAX_HEIGHT = 240;  // px
+
+async function captureScreenshot(): Promise<Screenshot> {
+  const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
+  if (!mainTarget) {
+    throw new Error('Could not find main target');
+  }
+
+  const {data} = await mainTarget.pageAgent().invoke_captureScreenshot({});
+  return ('data:image/png;base64,' + data) as Screenshot;
+}
+
+export async function resizeScreenshot(data: Screenshot): Promise<Screenshot> {
+  const img = new Image();
+  const promise = new Promise(resolve => {
+    img.>
+  });
+  img.src = data;
+  await promise;
+
+  const canvas = document.createElement('canvas');
+  const context = canvas.getContext('2d');
+  if (!context) {
+    throw new Error('Could not create context.');
+  }
+  const aspectRatio = img.width / img.height;
+  canvas.width = SCREENSHOT_WIDTH;
+  canvas.height = Math.min(
+      SCREENSHOT_MAX_HEIGHT,
+      SCREENSHOT_WIDTH / aspectRatio,
+  );
+  const bitmap = await createImageBitmap(img, {
+    resizeWidth: SCREENSHOT_WIDTH,
+    resizeQuality: 'high',
+  });
+  context.drawImage(bitmap, 0, 0);
+
+  return canvas.toDataURL('image/png') as Screenshot;
+}
+
+export async function takeScreenshot(): Promise<Screenshot> {
+  const data = await captureScreenshot();
+  return await resizeScreenshot(data);
+}
diff --git a/front_end/panels/recorder/models/Section.ts b/front_end/panels/recorder/models/Section.ts
new file mode 100644
index 0000000..1a645bc
--- /dev/null
+++ b/front_end/panels/recorder/models/Section.ts
@@ -0,0 +1,62 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {type Step} from './Schema.js';
+import {type Screenshot} from './ScreenshotStorage.js';
+
+export interface Section {
+  title: string;
+  steps: Step[];
+  url: string;
+  screenshot?: Screenshot;
+  causingStep?: Step;
+}
+
+function startNewSection(step: Step): Section|null {
+  const navigationEvent = step.assertedEvents?.find(
+      event => event.type === 'navigation',
+  );
+  if (step.type === 'navigate') {
+    return {
+      title: navigationEvent?.title || '',
+      url: step.url,
+      steps: [],
+      causingStep: step,
+    };
+  }
+  if (navigationEvent) {
+    return {
+      title: navigationEvent.title || '',
+      url: navigationEvent.url || '',
+      steps: [],
+    };
+  }
+  return null;
+}
+
+export function buildSections(steps: Step[]): Section[] {
+  let currentSection: Section|null = null;
+  const sections: Section[] = [];
+  for (const step of steps) {
+    if (currentSection) {
+      currentSection.steps.push(step);
+    } else if (step.type === 'navigate') {
+      currentSection = startNewSection(step);
+      continue;
+    } else {
+      currentSection = {title: 'Current page', url: '', steps: [step]};
+    }
+    const nextSection = startNewSection(step);
+    if (nextSection) {
+      if (currentSection) {
+        sections.push(currentSection);
+      }
+      currentSection = nextSection;
+    }
+  }
+  if (currentSection && (!sections.length || currentSection.steps.length)) {
+    sections.push(currentSection);
+  }
+  return sections;
+}
diff --git a/front_end/panels/recorder/models/Tooltip.ts b/front_end/panels/recorder/models/Tooltip.ts
new file mode 100644
index 0000000..2b45334
--- /dev/null
+++ b/front_end/panels/recorder/models/Tooltip.ts
@@ -0,0 +1,20 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type * as Common from '../../../core/common/common.js';
+import * as UI from '../../../ui/legacy/legacy.js';
+
+export function getTooltipForActions(
+    translation: string|Common.UIString.LocalizedString,
+    action: string,
+    ): string {
+  let title: string = translation;
+  const shortcuts = UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutsForAction(action);
+
+  for (const shortcut of shortcuts) {
+    title += ` - ${shortcut.title()}`;
+  }
+
+  return title;
+}
diff --git a/front_end/panels/recorder/models/models.ts b/front_end/panels/recorder/models/models.ts
new file mode 100644
index 0000000..3ca61e4
--- /dev/null
+++ b/front_end/panels/recorder/models/models.ts
@@ -0,0 +1,35 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ConverterIds from './ConverterIds.js';
+import * as RecorderSettings from './RecorderSettings.js';
+import * as RecorderShortcutHelper from './RecorderShortcutHelper.js';
+import * as RecordingPlayer from './RecordingPlayer.js';
+import * as RecordingSession from './RecordingSession.js';
+import * as RecordingSettings from './RecordingSettings.js';
+import * as RecordingStorage from './RecordingStorage.js';
+import * as Schema from './Schema.js';
+import * as SchemaUtils from './SchemaUtils.js';
+import * as ScreenshotStorage from './ScreenshotStorage.js';
+import * as ScreenshotUtils from './ScreenshotUtils.js';
+import * as SDKUtils from './SDKUtils.js';
+import * as Section from './Section.js';
+import * as Tooltip from './Tooltip.js';
+
+export {
+  ConverterIds,
+  RecorderSettings,
+  RecorderShortcutHelper,
+  RecordingPlayer,
+  RecordingSession,
+  RecordingSettings,
+  RecordingStorage,
+  Schema,
+  SchemaUtils,
+  ScreenshotStorage,
+  ScreenshotUtils,
+  SDKUtils,
+  Section,
+  Tooltip,
+};
diff --git a/front_end/panels/recorder/models/module.json b/front_end/panels/recorder/models/module.json
new file mode 100644
index 0000000..00ffdc4
--- /dev/null
+++ b/front_end/panels/recorder/models/module.json
@@ -0,0 +1,10 @@
+{
+  "dependencies": [
+    "ui/legacy",
+    "models/persistence",
+    "panels/accessibility",
+    "panels/elements",
+    "third_party/puppeteer"
+  ],
+  "resources": []
+}
diff --git a/front_end/panels/recorder/recorder-actions.ts b/front_end/panels/recorder/recorder-actions.ts
new file mode 100644
index 0000000..64c97c1
--- /dev/null
+++ b/front_end/panels/recorder/recorder-actions.ts
@@ -0,0 +1,10 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export const enum RecorderActions {
+  CreateRecording = 'chrome_recorder.create-recording',
+  StartRecording = 'chrome_recorder.start-recording',
+  ReplayRecording = 'chrome_recorder.replay-recording',
+  ToggleCodeView = 'chrome_recorder.toggle-code-view',
+}
diff --git a/front_end/panels/recorder/recorder-meta.ts b/front_end/panels/recorder/recorder-meta.ts
new file mode 100644
index 0000000..99cd1bb
--- /dev/null
+++ b/front_end/panels/recorder/recorder-meta.ts
@@ -0,0 +1,169 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as i18n from '../../core/i18n/i18n.js';
+import * as UI from '../../ui/legacy/legacy.js';
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import type * as Recorder from './recorder.js';
+import * as Actions from './recorder-actions.js';
+
+const UIStrings = {
+  /**
+   *@description Title of the Recorder Panel
+   */
+  recorder: 'Recorder',
+  /**
+   *@description Title of the Recorder Panel
+   */
+  showRecorder: 'Show Recorder',
+  /**
+   *@description Title of start/stop recording action in command menu
+   */
+  startStopRecording: 'Start/Stop recording',
+  /**
+   *@description Title of create a new recording action in command menu
+   */
+  createRecording: 'Create a new recording',
+  /**
+   *@description Title of start a new recording action in command menu
+   */
+  replayRecording: 'Replay recording',
+  /**
+   * @description Title for toggling code action in command menu
+   */
+  toggleCode: 'Toggle code view',
+};
+
+// TODO: (crbug.com/1181019)
+const RecorderCategory = 'Recorder';
+
+const str_ = i18n.i18n.registerUIStrings(
+    'panels/recorder/recorder-meta.ts',
+    UIStrings,
+);
+const i18nLazyString = i18n.i18n.getLazilyComputedLocalizedString.bind(
+    undefined,
+    str_,
+);
+
+let loadedRecorderModule: typeof Recorder|undefined;
+
+async function loadRecorderModule(): Promise<typeof Recorder> {
+  if (!loadedRecorderModule) {
+    loadedRecorderModule = await import('./recorder.js');
+  }
+  return loadedRecorderModule;
+}
+
+function maybeRetrieveContextTypes<T = unknown>(
+    getClassCallBack: (loadedRecorderModule: typeof Recorder) => T[],
+    actionId: string,
+    ): T[] {
+  if (loadedRecorderModule === undefined) {
+    return [];
+  }
+  if (actionId &&
+      loadedRecorderModule.RecorderPanel.RecorderPanel.instance().isActionPossible(
+          actionId as Actions.RecorderActions,
+          )) {
+    return getClassCallBack(loadedRecorderModule);
+  }
+  return [];
+}
+
+(UI.ViewManager.defaultOptionsForTabs as {[key: string]: boolean}).chrome_recorder = true;
+
+UI.ViewManager.registerViewExtension({
+  location: UI.ViewManager.ViewLocationValues.PANEL,
+  id: 'chrome_recorder',
+  commandPrompt: i18nLazyString(UIStrings.showRecorder),
+  title: i18nLazyString(UIStrings.recorder),
+  order: 90,
+  persistence: UI.ViewManager.ViewPersistence.CLOSEABLE,
+  isPreviewFeature: true,
+  async loadView() {
+    const Recorder = await loadRecorderModule();
+    return Recorder.RecorderPanel.RecorderPanel.instance();
+  },
+});
+
+UI.ActionRegistration.registerActionExtension({
+  category: RecorderCategory as UI.ActionRegistration.ActionCategory,
+  actionId: Actions.RecorderActions.CreateRecording,
+  title: i18nLazyString(UIStrings.createRecording),
+  async loadActionDelegate() {
+    const Recorder = await loadRecorderModule();
+    return Recorder.RecorderPanel.ActionDelegate.instance();
+  },
+});
+
+UI.ActionRegistration.registerActionExtension({
+  category: RecorderCategory as UI.ActionRegistration.ActionCategory,
+  actionId: Actions.RecorderActions.StartRecording,
+  title: i18nLazyString(UIStrings.startStopRecording),
+  contextTypes() {
+    return maybeRetrieveContextTypes(
+        Recorder => [Recorder.RecorderPanel.RecorderPanel],
+        Actions.RecorderActions.StartRecording,
+    );
+  },
+  async loadActionDelegate() {
+    const Recorder = await loadRecorderModule();
+    return Recorder.RecorderPanel.ActionDelegate.instance();
+  },
+  bindings: [
+    {
+      shortcut: 'Ctrl+E',
+      platform: UI.ActionRegistration.Platforms.WindowsLinux,
+    },
+    {shortcut: 'Meta+E', platform: UI.ActionRegistration.Platforms.Mac},
+  ],
+});
+
+UI.ActionRegistration.registerActionExtension({
+  category: RecorderCategory as UI.ActionRegistration.ActionCategory,
+  actionId: Actions.RecorderActions.ReplayRecording,
+  title: i18nLazyString(UIStrings.replayRecording),
+  contextTypes() {
+    return maybeRetrieveContextTypes(
+        Recorder => [Recorder.RecorderPanel.RecorderPanel],
+        Actions.RecorderActions.ReplayRecording,
+    );
+  },
+  async loadActionDelegate() {
+    const Recorder = await loadRecorderModule();
+    return Recorder.RecorderPanel.ActionDelegate.instance();
+  },
+  bindings: [
+    {
+      shortcut: 'Ctrl+Enter',
+      platform: UI.ActionRegistration.Platforms.WindowsLinux,
+    },
+    {shortcut: 'Meta+Enter', platform: UI.ActionRegistration.Platforms.Mac},
+  ],
+});
+
+UI.ActionRegistration.registerActionExtension({
+  category: RecorderCategory as UI.ActionRegistration.ActionCategory,
+  actionId: Actions.RecorderActions.ToggleCodeView,
+  title: i18nLazyString(UIStrings.toggleCode),
+  contextTypes() {
+    return maybeRetrieveContextTypes(
+        Recorder => [Recorder.RecorderPanel.RecorderPanel],
+        Actions.RecorderActions.ToggleCodeView,
+    );
+  },
+  async loadActionDelegate() {
+    const Recorder = await loadRecorderModule();
+    return Recorder.RecorderPanel.ActionDelegate.instance();
+  },
+  bindings: [
+    {
+      shortcut: 'Ctrl+B',
+      platform: UI.ActionRegistration.Platforms.WindowsLinux,
+    },
+    {shortcut: 'Meta+B', platform: UI.ActionRegistration.Platforms.Mac},
+  ],
+});
diff --git a/front_end/panels/recorder/recorder.ts b/front_end/panels/recorder/recorder.ts
new file mode 100644
index 0000000..6bbad77
--- /dev/null
+++ b/front_end/panels/recorder/recorder.ts
@@ -0,0 +1,9 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as RecorderController from './RecorderController.js';
+import * as RecorderEvents from './RecorderEvents.js';
+import * as RecorderPanel from './RecorderPanel.js';
+
+export {RecorderController, RecorderEvents, RecorderPanel};
diff --git a/front_end/panels/recorder/recorderController.css b/front_end/panels/recorder/recorderController.css
new file mode 100644
index 0000000..3a1ce70
--- /dev/null
+++ b/front_end/panels/recorder/recorderController.css
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2023 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+  font-size: inherit;
+}
+
+*:focus,
+*:focus-visible {
+  outline: none;
+}
+
+:host {
+  overflow-x: auto;
+}
+
+:host,
+devtools-recording-view,
+devtools-create-recording-view {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-height: 0;
+}
+
+.wrapper {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.header {
+  background-color: var(--color-background);
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  border-bottom: 1px solid var(--color-details-hairline);
+  padding: 0 5px;
+  min-height: 29px;
+  max-height: 29px;
+  gap: 3px;
+}
+
+.separator {
+  background-color: var(--color-details-hairline);
+  width: 1px;
+  height: 17px;
+  margin: 0;
+}
+
+select {
+  border-radius: 2px;
+  border: 1px solid transparent;
+  height: 24px;
+  max-width: 180px;
+  min-width: 140px;
+  padding: 0 5px;
+  position: relative;
+  color: var(--color-text-primary);
+  background-color: var(--color-background);
+  text-overflow: ellipsis;
+}
+
+select:disabled {
+  color: var(--color-input-text-disabled);
+}
+
+select:not([disabled]):hover,
+select:not([disabled]):focus-visible,
+select:not([disabled]):active {
+  background-color: var(--color-iconbutton-hover);
+}
+
+select:not([disabled]):focus-visible {
+  box-shadow: 0 0 0 2px var(--color-button-outline-focus);
+}
+
+select option {
+  background-color: var(--color-background-elevation-1);
+  color: var(--color-text-primary);
+}
+
+devtools-menu {
+  width: 0;
+  height: 0;
+  position: absolute;
+}
+
+devtools-recording-list-view {
+  overflow: auto;
+}
+
+.error {
+  color: var(--color-error-text);
+  border: 1px solid var(--color-error-border);
+  background-color: var(--color-error-background);
+  padding: 4px;
+}
+
+.feedback {
+  margin-left: auto;
+  margin-right: 4px;
+}
+
+.feedback .x-link {
+  letter-spacing: 0.03em;
+  text-decoration-line: underline;
+  font-size: 9px;
+  line-height: 16px;
+  color: var(--color-text-secondary);
+  outline-offset: 3px;
+}
+
+.feedback .x-link:focus-visible {
+  outline: -webkit-focus-ring-color auto 1px;
+}
+
+.continue-button {
+  border: none;
+  background-color: transparent;
+  width: 24px;
+  height: 24px;
+  border-radius: 2px;
+}
+
+.continue-button devtools-icon {
+  width: 24px;
+  height: 24px;
+
+  --icon-color: var(--color-primary-old);
+}
+
+.continue-button:hover,
+.continue-button:focus-visible {
+  background-color: var(--color-iconbutton-hover);
+}
+
+.continue-button:disabled {
+  background: var(--color-background);
+  color: var(--color-text-disabled);
+  cursor: not-allowed;
+}
+
+.continue-button:disabled devtools-icon {
+  --icon-color: var(--color-text-disabled);
+}
+
+devtools-shortcut-dialog {
+  padding-right: 6px;
+}
diff --git a/front_end/panels/recorder/util/BUILD.gn b/front_end/panels/recorder/util/BUILD.gn
new file mode 100644
index 0000000..e4d30d2
--- /dev/null
+++ b/front_end/panels/recorder/util/BUILD.gn
@@ -0,0 +1,27 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_module.gni")
+
+devtools_module("util") {
+  sources = [ "SharedObject.ts" ]
+
+  deps = [ "../../../core/common:bundle" ]
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "util.ts"
+
+  deps = [
+    ":util",
+    "../../../core/platform:bundle",
+  ]
+
+  visibility = [
+    "../*",
+    "../../../../test/unittests/front_end/panels/recorder/*",
+  ]
+}
diff --git a/front_end/panels/recorder/util/SharedObject.ts b/front_end/panels/recorder/util/SharedObject.ts
new file mode 100644
index 0000000..13d2d7a
--- /dev/null
+++ b/front_end/panels/recorder/util/SharedObject.ts
@@ -0,0 +1,87 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../../core/common/common.js';
+
+type Awaitable<T> = Promise<T>|T;
+
+export type ReleaseFunction = () => Promise<void>;
+
+/**
+ * SharedObject is similar to a C++ shared pointer, i.e. a reference counted
+ * object.
+ *
+ * A object is "created" whenever there are no acquirers and it's then acquired.
+ * Subsequent acquirers use the same object. Only until all acquirers release
+ * will the object be "destroyed".
+ *
+ * Using an object after it's destroyed is undefined behavior.
+ *
+ * The definition of "created" and "destroyed" is dependent on the functions
+ * passed into the constructor.
+ */
+export class SharedObject<T> {
+  #mutex = new Common.Mutex.Mutex();
+  #counter = 0;
+  #value: T|undefined;
+
+  #create: () => Awaitable<T>;
+  #destroy: (value: T) => Awaitable<void>;
+
+  constructor(create: () => Awaitable<T>, destroy: (value: T) => Awaitable<void>) {
+    this.#create = create;
+    this.#destroy = destroy;
+  }
+
+  /**
+   * @returns The shared object and a release function. If the release function
+   * throws, you may attempt to call it again (however this probably implies
+   * your destroy function is bad).
+   */
+  async acquire(): Promise<[T, ReleaseFunction]> {
+    await this.#mutex.run(async () => {
+      if (this.#counter === 0) {
+        this.#value = await this.#create();
+      }
+      ++this.#counter;
+    });
+    return [this.#value as T, this.#release.bind(this, {released: false})];
+  }
+
+  /**
+   * Automatically perform an acquire and release.
+   *
+   * **If the release fails**, then this will throw and the object will be
+   * permanently alive. This is expected to be a fatal error and you should
+   * debug your destroy function.
+   */
+  async run<U>(action: (value: T) => Awaitable<U>): Promise<U> {
+    const [value, release] = await this.acquire();
+    try {
+      const result = await action(value);
+      return result;
+    } finally {
+      await release();
+    }
+  }
+
+  async #release(state: {released: boolean}): Promise<void> {
+    if (state.released) {
+      throw new Error('Attempted to release object multiple times.');
+    }
+    try {
+      state.released = true;
+      await this.#mutex.run(async () => {
+        if (this.#counter === 1) {
+          await this.#destroy(this.#value as T);
+          this.#value = undefined;
+        }
+        --this.#counter;
+      });
+    } catch (error) {
+      state.released = false;
+      throw error;
+    }
+  }
+}
diff --git a/front_end/panels/recorder/util/util.ts b/front_end/panels/recorder/util/util.ts
new file mode 100644
index 0000000..6eec514
--- /dev/null
+++ b/front_end/panels/recorder/util/util.ts
@@ -0,0 +1,32 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Platform from '../../../core/platform/platform.js';
+
+import * as SharedObject from './SharedObject.js';
+
+let isDebugBuild: boolean;
+try {
+  Platform.DCHECK(() => false);
+  isDebugBuild = false;
+} catch {
+  isDebugBuild = true;
+}
+
+const DEVTOOLS_RECORDER_WORLD_NAME = 'devtools_recorder';
+
+class InjectedScript {
+  static #injectedScript?: Promise<string>;
+  static async get(): Promise<string> {
+    if (!this.#injectedScript) {
+      this.#injectedScript = (await fetch(
+                                  new URL('../injected/injected.generated.js', import.meta.url),
+                                  ))
+                                 .text();
+    }
+    return this.#injectedScript;
+  }
+}
+
+export {isDebugBuild, InjectedScript, DEVTOOLS_RECORDER_WORLD_NAME, SharedObject};
diff --git a/front_end/panels/timeline/BUILD.gn b/front_end/panels/timeline/BUILD.gn
index ddb6b99..5920676 100644
--- a/front_end/panels/timeline/BUILD.gn
+++ b/front_end/panels/timeline/BUILD.gn
@@ -88,6 +88,7 @@
     "../../entrypoints/*",
     "../../ui/components/docs/performance_panel/*",
     "../js_profiler/*",
+    "../recorder/*",
 
     # TODO(crbug.com/1202788): Remove invalid dependents
     "../input/*",
diff --git a/front_end/services/tracing/BUILD.gn b/front_end/services/tracing/BUILD.gn
new file mode 100644
index 0000000..a3d5bb6
--- /dev/null
+++ b/front_end/services/tracing/BUILD.gn
@@ -0,0 +1,20 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../scripts/build/ninja/devtools_module.gni")
+
+devtools_module("tracing") {
+  sources = [ "PerformanceTracing.ts" ]
+
+  deps = [ "../../../front_end/core/sdk:bundle" ]
+}
+
+devtools_entrypoint("bundle") {
+  entrypoint = "tracing.ts"
+
+  deps = [ ":tracing" ]
+
+  visibility = [ "*" ]
+}
diff --git a/front_end/services/tracing/PerformanceTracing.ts b/front_end/services/tracing/PerformanceTracing.ts
new file mode 100644
index 0000000..0940ed1
--- /dev/null
+++ b/front_end/services/tracing/PerformanceTracing.ts
@@ -0,0 +1,95 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as SDK from '../../core/sdk/sdk.js';
+
+export class PerformanceTracing implements SDK.TracingManager.TracingManagerClient {
+  readonly #traceEvents: Object[] = [];
+  #tracingManager: SDK.TracingManager.TracingManager|null = null;
+  #delegate: Delegate;
+
+  constructor(target: SDK.Target.Target, delegate: Delegate) {
+    this.#tracingManager = target.model(SDK.TracingManager.TracingManager);
+    this.#delegate = delegate;
+  }
+
+  async start(): Promise<void> {
+    this.#traceEvents.length = 0;
+
+    if (!this.#tracingManager) {
+      throw new Error('No tracing manager');
+    }
+
+    // This panel may be opened with trace data recorded in other tools.
+    // Keep in sync with the categories arrays in:
+    // https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/panels/timeline/TimelineController.ts
+    // https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/gather/gatherers/trace.js
+    const categories = [
+      '-*',
+      'blink.console',
+      'blink.user_timing',
+      'devtools.timeline',
+      'disabled-by-default-devtools.screenshot',
+      'disabled-by-default-devtools.timeline',
+      'disabled-by-default-devtools.timeline.invalidationTracking',
+      'disabled-by-default-devtools.timeline.frame',
+      'disabled-by-default-devtools.timeline.stack',
+      'disabled-by-default-v8.cpu_profiler',
+      'disabled-by-default-v8.cpu_profiler.hires',
+      'latencyInfo',
+      'loading',
+      'disabled-by-default-lighthouse',
+      'v8.execute',
+      'v8',
+    ].join(',');
+
+    const started = await this.#tracingManager.start(this, categories, '');
+
+    if (!started) {
+      throw new Error('Unable to start tracing.');
+    }
+  }
+
+  async stop(): Promise<void> {
+    return this.#tracingManager?.stop();
+  }
+
+  // Start of implementation of SDK.TracingManager.TracingManagerClient
+  getTraceEvents(): Object[] {
+    return this.#traceEvents;
+  }
+
+  traceEventsCollected(events: Object[]): void {
+    this.#traceEvents.push(...events);
+  }
+
+  tracingBufferUsage(usage: number): void {
+    this.#delegate.tracingBufferUsage(usage);
+  }
+
+  eventsRetrievalProgress(progress: number): void {
+    this.#delegate.eventsRetrievalProgress(progress);
+  }
+
+  tracingComplete(): void {
+    this.#delegate.tracingComplete(this.#traceEvents);
+  }
+  // End of implementation of SDK.TracingManager.TracingManagerClient
+}
+
+interface Delegate {
+  tracingBufferUsage(usage: number): void;
+  eventsRetrievalProgress(progress: number): void;
+  tracingComplete(events: Object[]): void;
+}
+
+// Used by an implementation of Common.Revealer to transfer data from the recorder to the performance panel.
+export class RawTraceEvents {
+  constructor(private events: Object[]) {
+  }
+
+  getEvents(): Object[] {
+    return this.events;
+  }
+}
diff --git a/front_end/services/tracing/tracing.ts b/front_end/services/tracing/tracing.ts
new file mode 100644
index 0000000..b893324
--- /dev/null
+++ b/front_end/services/tracing/tracing.ts
@@ -0,0 +1,9 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as PerformanceTracing from './PerformanceTracing.js';
+
+export {
+  PerformanceTracing,
+};
diff --git a/front_end/third_party/additional_readme_paths.json b/front_end/third_party/additional_readme_paths.json
index 09ee2ca..bd37ab4 100644
--- a/front_end/third_party/additional_readme_paths.json
+++ b/front_end/third_party/additional_readme_paths.json
@@ -10,5 +10,6 @@
   "lit",
   "marked",
   "puppeteer",
+  "puppeteer-replay",
   "wasmparser"
 ]
diff --git a/front_end/third_party/puppeteer-replay/BUILD.gn b/front_end/third_party/puppeteer-replay/BUILD.gn
index 2d7b533..b2e88ed 100644
--- a/front_end/third_party/puppeteer-replay/BUILD.gn
+++ b/front_end/third_party/puppeteer-replay/BUILD.gn
@@ -2,8 +2,8 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
-import("../../../devtools-frontend/scripts/build/ninja/devtools_entrypoint.gni")
-import("../../../devtools-frontend/scripts/build/ninja/devtools_pre_built.gni")
+import("../../../scripts/build/ninja/devtools_entrypoint.gni")
+import("../../../scripts/build/ninja/devtools_pre_built.gni")
 
 EXCLUDED_SOURCES = [
   "package/lib/cli.js",
diff --git a/front_end/ui/components/dialogs/BUILD.gn b/front_end/ui/components/dialogs/BUILD.gn
index bdf1a8b..3a7d3f6 100644
--- a/front_end/ui/components/dialogs/BUILD.gn
+++ b/front_end/ui/components/dialogs/BUILD.gn
@@ -35,10 +35,5 @@
     ":dialogs",
   ]
 
-  visibility = [
-    "../*",
-    "../../../../test/interactions/*",
-    "../../../../test/screenshots/*",
-    "../../../../test/unittests/front_end/*",
-  ]
+  visibility = [ "*" ]
 }
diff --git a/front_end/ui/components/docs/BUILD.gn b/front_end/ui/components/docs/BUILD.gn
index 76d8524..1ae3a8b 100644
--- a/front_end/ui/components/docs/BUILD.gn
+++ b/front_end/ui/components/docs/BUILD.gn
@@ -50,6 +50,14 @@
     "./trust_tokens_view",
     "./two_states_counter",
     "./user_agent_client_hints",
+    "./recorder_control_button",
+    "./recorder_create_recording_view",
+    "./recorder_injected",
+    "./recorder_recording_list_view",
+    "./recorder_recording_view",
+    "./recorder_select_button",
+    "./recorder_split_view",
+    "./recorder_start_view",
   ]
 }
 
diff --git a/front_end/ui/components/docs/recorder_control_button/BUILD.gn b/front_end/ui/components/docs/recorder_control_button/BUILD.gn
new file mode 100644
index 0000000..3d0a559
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_control_button/BUILD.gn
@@ -0,0 +1,23 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../scripts/build/ninja/copy.gni")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+  testonly = true
+  sources = [ "basic.ts" ]
+
+  deps = [
+    "../../../../../front_end/ui/components/helpers:bundle",
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../panels/recorder/components:bundle",
+  ]
+}
+
+copy_to_gen("recorder_control_button") {
+  testonly = true
+  sources = [ "basic.html" ]
+  deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/recorder_control_button/basic.html b/front_end/ui/components/docs/recorder_control_button/basic.html
new file mode 100644
index 0000000..300d4dd
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_control_button/basic.html
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<!DOCTYPE html>
+<html>
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width" />
+  <title>Recorder: ControlButton</title>
+</head>
+
+<body>
+  <div id="container"></div>
+  <script src="./basic.js" type="module"></script>
+</body>
+
+</html>
diff --git a/front_end/ui/components/docs/recorder_control_button/basic.ts b/front_end/ui/components/docs/recorder_control_button/basic.ts
new file mode 100644
index 0000000..730fac6
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_control_button/basic.ts
@@ -0,0 +1,15 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ComponentHelpers from '../../../../../front_end/ui/components/helpers/helpers.js';
+import * as FrontendHelpers from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';  // eslint-disable-line rulesdir/es_modules_import
+import * as RecorderComponents from '../../../../panels/recorder/components/components.js';
+
+await ComponentHelpers.ComponentServerSetup.setup();
+await FrontendHelpers.initializeGlobalVars();
+
+const component = new RecorderComponents.ControlButton.ControlButton();
+component.shape = 'circle';
+component.label = 'ControlButton';
+document.getElementById('container')?.appendChild(component);
diff --git a/front_end/ui/components/docs/recorder_create_recording_view/BUILD.gn b/front_end/ui/components/docs/recorder_create_recording_view/BUILD.gn
new file mode 100644
index 0000000..2b7e60a
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_create_recording_view/BUILD.gn
@@ -0,0 +1,23 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../scripts/build/ninja/copy.gni")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+  testonly = true
+  sources = [ "basic.ts" ]
+
+  deps = [
+    "../../../../../front_end/ui/components/helpers:bundle",
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../panels/recorder/components:bundle",
+  ]
+}
+
+copy_to_gen("recorder_create_recording_view") {
+  testonly = true
+  sources = [ "basic.html" ]
+  deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/recorder_create_recording_view/basic.html b/front_end/ui/components/docs/recorder_create_recording_view/basic.html
new file mode 100644
index 0000000..d49e5c0
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_create_recording_view/basic.html
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<!DOCTYPE html>
+<html>
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width" />
+  <title>Recorder: CreateRecordingView</title>
+</head>
+
+<body>
+  <div id="container"></div>
+  <script src="./basic.js" type="module"></script>
+</body>
+
+</html>
diff --git a/front_end/ui/components/docs/recorder_create_recording_view/basic.ts b/front_end/ui/components/docs/recorder_create_recording_view/basic.ts
new file mode 100644
index 0000000..1a05e70
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_create_recording_view/basic.ts
@@ -0,0 +1,27 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ComponentHelpers from '../../../../../front_end/ui/components/helpers/helpers.js';
+import * as FrontendHelpers from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';  // eslint-disable-line rulesdir/es_modules_import
+import * as RecorderComponents from '../../../../panels/recorder/components/components.js';
+
+await ComponentHelpers.ComponentServerSetup.setup();
+await FrontendHelpers.initializeGlobalVars();
+async function initializeGlobalActions(): Promise<void> {
+  const UI = await import('../../../../../front_end/ui/legacy/legacy.js');
+  const actionRegistry = UI.ActionRegistry.ActionRegistry.instance();
+  UI.ShortcutRegistry.ShortcutRegistry.instance({
+    forceNew: true,
+    actionRegistry,
+  });
+}
+
+await initializeGlobalActions();
+
+const component1 = new RecorderComponents.CreateRecordingView.CreateRecordingView();
+document.getElementById('container')?.appendChild(component1);
+
+const component2 = new RecorderComponents.CreateRecordingView.CreateRecordingView();
+document.getElementById('container')?.appendChild(component2);
+component2.shadowRoot?.querySelector('devtools-control-button')?.click();
diff --git a/front_end/ui/components/docs/recorder_injected/BUILD.gn b/front_end/ui/components/docs/recorder_injected/BUILD.gn
new file mode 100644
index 0000000..4b1811f
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_injected/BUILD.gn
@@ -0,0 +1,22 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../scripts/build/ninja/copy.gni")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+  testonly = true
+  sources = [ "basic.ts" ]
+  deps = [
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../panels/recorder/injected:bundle",
+  ]
+}
+
+copy_to_gen("recorder_injected") {
+  testonly = true
+  sources = [ "basic.html" ]
+
+  deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/recorder_injected/basic.html b/front_end/ui/components/docs/recorder_injected/basic.html
new file mode 100644
index 0000000..d6f0217
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_injected/basic.html
@@ -0,0 +1,100 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<html>
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width" />
+    <title>Recording Client example</title>
+    <style>
+      html, body, * {
+        margin: 0;
+        padding: 0;
+      }
+      button {
+        width: 100px;
+        height: 20px;
+      }
+    </style>
+  </head>
+  <body>
+
+    <button aria-role="button" aria-name="testButton" id="button"></button>
+    <button id="buttonNoARIA"></button>
+    <button id="buttonWithLength11">length a 11</button>
+    <button id="buttonWithLength12">length aa 12</button>
+    <button id="buttonWithLength32">length aaaaaaaaa aaaaaaaaa aa 32</button>
+    <button id="buttonWithLength33">length aaaaaaaaa aaaaaaaaa aaa 33</button>
+    <button id="buttonWithLength64">length aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaa 64</button>
+    <button id="buttonWithLength65">length aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaaa 65</button>
+    <button id="buttonWithNewLines">
+      with newlines
+    </button>
+    <input id="input"></input>
+
+    <script>
+      class ShadowCSSSelectorElement extends HTMLElement {
+        constructor() {
+          super();
+          const shadow = this.attachShadow({mode: 'open'});
+          shadow.innerHTML = `
+            <p>sss</p>
+            <button id="insideShadowRoot">Login</button>
+          `;
+        }
+      }
+      customElements.define('shadow-css-selector-element', ShadowCSSSelectorElement);
+
+      class ShadowARIASelectorElement extends HTMLElement {
+        constructor() {
+          super();
+          const shadow = this.attachShadow({mode: 'open'});
+          shadow.innerHTML = `
+            <p>sss</p>
+            <button aria-role="button" aria-name="login">Login</button>
+          `;
+        }
+      }
+      customElements.define('shadow-aria-selector-element', ShadowARIASelectorElement);
+    </script>
+    <header>
+      <shadow-css-selector-element></shadow-css-selector-element>
+    </header>
+    <main>
+      <shadow-css-selector-element></shadow-css-selector-element>
+    </main>
+
+    <div aria-role="header">
+      <shadow-aria-selector-element></shadow-aria-selector-element>
+    </div>
+    <div aria-role="main">
+      <shadow-aria-selector-element></shadow-aria-selector-element>
+    </div>
+
+    <div aria-name="parent-name">
+      <div id="no-aria-name-or-role" aria-name="" aria-role="">
+    </div>
+
+    <host-element id="slotted-host-element">
+      <template shadowroot="open">
+        <slot></slot>
+      </template>
+      text in slot
+    </host-element>
+
+    <button class="custom-selector-attribute" data-testid="unique">Custom selector</button>
+    <button class="custom-selector-attribute" data-testid="123456789">Custom selector (invalid CSS id)</button>
+
+    <host-element id="shadow-root-with-custom-selectors" data-qa="custom-id">
+      <template shadowroot="open">
+        <button data-testid="shadow button">Shadow button with testid</button>>
+      </template>
+    </host-element>
+
+    <div id="notunique"></div>
+    <div id="notunique"></div>
+    <script type="module" src="./basic.js"></script>
+  </body>
+</html>
diff --git a/front_end/ui/components/docs/recorder_injected/basic.ts b/front_end/ui/components/docs/recorder_injected/basic.ts
new file mode 100644
index 0000000..a43df15
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_injected/basic.ts
@@ -0,0 +1,5 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../../../../panels/recorder/injected/injected.js';
diff --git a/front_end/ui/components/docs/recorder_recording_list_view/BUILD.gn b/front_end/ui/components/docs/recorder_recording_list_view/BUILD.gn
new file mode 100644
index 0000000..c67c40c
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_recording_list_view/BUILD.gn
@@ -0,0 +1,23 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../scripts/build/ninja/copy.gni")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+  testonly = true
+  sources = [ "basic.ts" ]
+
+  deps = [
+    "../../../../../front_end/ui/components/helpers:bundle",
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../panels/recorder/components:bundle",
+  ]
+}
+
+copy_to_gen("recorder_recording_list_view") {
+  testonly = true
+  sources = [ "basic.html" ]
+  deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/recorder_recording_list_view/basic.html b/front_end/ui/components/docs/recorder_recording_list_view/basic.html
new file mode 100644
index 0000000..39054f2
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_recording_list_view/basic.html
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<!DOCTYPE html>
+<html>
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width" />
+  <title>Recorder: RecordingListView</title>
+</head>
+
+<body>
+  <div id="container"></div>
+  <script src="./basic.js" type="module"></script>
+</body>
+
+</html>
diff --git a/front_end/ui/components/docs/recorder_recording_list_view/basic.ts b/front_end/ui/components/docs/recorder_recording_list_view/basic.ts
new file mode 100644
index 0000000..097734c
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_recording_list_view/basic.ts
@@ -0,0 +1,23 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ComponentHelpers from '../../../../../front_end/ui/components/helpers/helpers.js';
+import * as FrontendHelpers from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';  // eslint-disable-line rulesdir/es_modules_import
+import * as RecorderComponents from '../../../../panels/recorder/components/components.js';
+
+await ComponentHelpers.ComponentServerSetup.setup();
+await FrontendHelpers.initializeGlobalVars();
+
+const component = new RecorderComponents.RecordingListView.RecordingListView();
+component.recordings = [
+  {
+    storageName: '1',
+    name: 'Title 1',
+  },
+  {
+    storageName: '2',
+    name: 'Title 2',
+  },
+];
+document.getElementById('container')?.appendChild(component);
diff --git a/front_end/ui/components/docs/recorder_recording_view/BUILD.gn b/front_end/ui/components/docs/recorder_recording_view/BUILD.gn
new file mode 100644
index 0000000..506a4fa
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_recording_view/BUILD.gn
@@ -0,0 +1,24 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../scripts/build/ninja/copy.gni")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+  testonly = true
+  sources = [ "basic.ts" ]
+
+  deps = [
+    "../../../../../front_end/ui/components/helpers:bundle",
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../panels/recorder/components:bundle",
+    "../../../../panels/recorder/models:bundle",
+  ]
+}
+
+copy_to_gen("recorder_recording_view") {
+  testonly = true
+  sources = [ "basic.html" ]
+  deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/recorder_recording_view/basic.html b/front_end/ui/components/docs/recorder_recording_view/basic.html
new file mode 100644
index 0000000..547e706
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_recording_view/basic.html
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<!DOCTYPE html>
+<html>
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width" />
+  <title>Recorder: RecordingView</title>
+</head>
+
+<body>
+  <div id="container"></div>
+  <script src="./basic.js" type="module"></script>
+</body>
+
+</html>
diff --git a/front_end/ui/components/docs/recorder_recording_view/basic.ts b/front_end/ui/components/docs/recorder_recording_view/basic.ts
new file mode 100644
index 0000000..bda60a9
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_recording_view/basic.ts
@@ -0,0 +1,99 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ComponentHelpers from '../../../../../front_end/ui/components/helpers/helpers.js';
+import * as FrontendHelpers from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';  // eslint-disable-line rulesdir/es_modules_import
+import * as RecorderComponents from '../../../../panels/recorder/components/components.js';
+import * as Models from '../../../../panels/recorder/models/models.js';
+
+await ComponentHelpers.ComponentServerSetup.setup();
+await FrontendHelpers.initializeGlobalVars();
+
+async function initializeGlobalActions(): Promise<void> {
+  const UI = await import('../../../../../front_end/ui/legacy/legacy.js');
+  const actionRegistry = UI.ActionRegistry.ActionRegistry.instance();
+  UI.ShortcutRegistry.ShortcutRegistry.instance({
+    forceNew: true,
+    actionRegistry,
+  });
+}
+await initializeGlobalActions();
+
+const recording = {
+  title: 'Test',
+  steps: [
+    {
+      type: Models.Schema.StepType.Navigate as const,
+      url: 'http://example.com',
+    },
+    {
+      type: Models.Schema.StepType.Click as const,
+      target: 'main',
+      selectors: [['.click']],
+      offsetX: 1,
+      offsetY: 2,
+      assertedEvents: [{
+        type: Models.Schema.AssertedEventType.Navigation,
+        title: 'Test',
+        url: 'http://example.com/2',
+      }],
+    },
+    {
+      type: Models.Schema.StepType.Click as const,
+      target: 'main',
+      selectors: [['.click2']],
+      offsetX: 3,
+      offsetY: 4,
+    },
+  ],
+};
+const data = {
+  replayState: {
+    isPlaying: false,
+    isPausedOnBreakpoint: false,
+  },
+  isRecording: false,
+  recordingTogglingInProgress: false,
+  replayAllowed: true,
+  recording,
+  breakpointIndexes: new Set<number>(),
+  sections: [
+    {
+      title: 'Section title',
+      url: 'http://example.com',
+      steps: [recording.steps[1]],
+      causingStep: recording.steps[0],
+    },
+    {
+      title: 'Section title 2',
+      url: 'http://example.com/2',
+      steps: [recording.steps[2]],
+      causingStep: recording.steps[1],
+    },
+  ],
+  settings: {
+    networkConditionsSettings: {
+      download: 3000,
+      upload: 3000,
+      latency: 3000,
+      type: Models.Schema.StepType.EmulateNetworkConditions as const,
+    },
+    viewportSettings: {
+      deviceScaleFactor: 1,
+      hasTouch: false,
+      height: 800,
+      width: 600,
+      isLandscape: false,
+      isMobile: false,
+      type: Models.Schema.StepType.SetViewport as const,
+    },
+  },
+  builtInConverters: [],
+  extensionConverters: [],
+  recorderSettings: new Models.RecorderSettings.RecorderSettings(),
+  replayExtensions: [],
+};
+const component1 = new RecorderComponents.RecordingView.RecordingView();
+component1.data = data;
+document.getElementById('container')?.appendChild(component1);
diff --git a/front_end/ui/components/docs/recorder_select_button/BUILD.gn b/front_end/ui/components/docs/recorder_select_button/BUILD.gn
new file mode 100644
index 0000000..7bed48d
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_select_button/BUILD.gn
@@ -0,0 +1,23 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../scripts/build/ninja/copy.gni")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+  testonly = true
+  sources = [ "basic.ts" ]
+
+  deps = [
+    "../../../../../front_end/ui/components/helpers:bundle",
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../panels/recorder/components:bundle",
+  ]
+}
+
+copy_to_gen("recorder_select_button") {
+  testonly = true
+  sources = [ "basic.html" ]
+  deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/recorder_select_button/basic.html b/front_end/ui/components/docs/recorder_select_button/basic.html
new file mode 100644
index 0000000..e0e4a85
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_select_button/basic.html
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<!DOCTYPE html>
+<html>
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width" />
+  <title>Recorder: select button</title>
+</head>
+
+<body>
+  <div id="container"></div>
+  <script src="./basic.js" type="module"></script>
+</body>
+
+</html>
diff --git a/front_end/ui/components/docs/recorder_select_button/basic.ts b/front_end/ui/components/docs/recorder_select_button/basic.ts
new file mode 100644
index 0000000..adaf156
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_select_button/basic.ts
@@ -0,0 +1,78 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ComponentHelpers from '../../../../../front_end/ui/components/helpers/helpers.js';
+import * as LitHtml from '../../../../../front_end/ui/lit-html/lit-html.js';
+import * as FrontendHelpers from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';  // eslint-disable-line rulesdir/es_modules_import
+import * as RecorderComponents from '../../../../panels/recorder/components/components.js';
+
+await ComponentHelpers.ComponentServerSetup.setup();
+await FrontendHelpers.initializeGlobalVars();
+
+const container = document.getElementById('container');
+const throttlingIconUrl = new URL('../../../../panels/recorder/images/throttling_icon.svg', import.meta.url).toString();
+const playIconUrl = new URL('../../../../images/play_icon.svg', import.meta.url).toString();
+
+const items = [
+  {
+    value: 'performance',
+    label: (): string => 'Performance panel',
+    buttonIconUrl: throttlingIconUrl,
+  },
+  {
+    value: 'performance_insights',
+    label: (): string => 'Performance insights panel',
+    buttonIconUrl: throttlingIconUrl,
+  },
+];
+
+const replayItems = [
+  {
+    value: 'normal',
+    buttonIconUrl: playIconUrl,
+    buttonLabel: (): string => 'Replay',
+    label: (): string => 'Normal (Default)',
+  },
+  {value: 'slow', buttonIconUrl: playIconUrl, buttonLabel: (): string => 'Slow replay', label: (): string => 'Slow'},
+  {
+    value: 'very_slow',
+    buttonIconUrl: playIconUrl,
+    buttonLabel: (): string => 'Very slow replay',
+    label: (): string => 'Very slow',
+  },
+  {
+    value: 'extremely_slow',
+    buttonIconUrl: playIconUrl,
+    buttonLabel: (): string => 'Extremely slow replay',
+    label: (): string => 'Extremely slow',
+  },
+];
+
+function litRender(template: LitHtml.TemplateResult): void {
+  const div = document.createElement('div');
+  div.style.width = '400px';
+  div.style.display = 'flex';
+  div.style.margin = '10px';
+  div.style.flexDirection = 'row-reverse';
+  container?.appendChild(div);
+  LitHtml.render(template, div);  // eslint-disable-line
+}
+
+litRender(LitHtml.html`
+    <${RecorderComponents.SelectButton.SelectButton.litTagName} .items=${items} .value=${items[0].value}></${
+    RecorderComponents.SelectButton.SelectButton.litTagName}>`);
+litRender(LitHtml.html`
+    <${RecorderComponents.SelectButton.SelectButton.litTagName} .disabled=${true} .items=${items} .value=${
+    items[0].value}></${RecorderComponents.SelectButton.SelectButton.litTagName}>`);
+litRender(LitHtml.html`
+    <${RecorderComponents.SelectButton.SelectButton.litTagName}
+    .variant=${RecorderComponents.SelectButton.Variant.SECONDARY}
+    .items=${replayItems}
+    .value=${replayItems[0].value}></${RecorderComponents.SelectButton.SelectButton.litTagName}>`);
+litRender(LitHtml.html`
+    <${RecorderComponents.SelectButton.SelectButton.litTagName}
+    .disabled=${true}
+    .variant=${RecorderComponents.SelectButton.Variant.SECONDARY}
+    .items=${replayItems}
+    .value=${replayItems[2].value}></${RecorderComponents.SelectButton.SelectButton.litTagName}>`);
diff --git a/front_end/ui/components/docs/recorder_split_view/BUILD.gn b/front_end/ui/components/docs/recorder_split_view/BUILD.gn
new file mode 100644
index 0000000..977367a
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_split_view/BUILD.gn
@@ -0,0 +1,23 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../scripts/build/ninja/copy.gni")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+  testonly = true
+  sources = [ "basic.ts" ]
+
+  deps = [
+    "../../../../../front_end/ui/components/helpers:bundle",
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../panels/recorder/components:bundle",
+  ]
+}
+
+copy_to_gen("recorder_split_view") {
+  testonly = true
+  sources = [ "basic.html" ]
+  deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/recorder_split_view/basic.html b/front_end/ui/components/docs/recorder_split_view/basic.html
new file mode 100644
index 0000000..5d67c8e
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_split_view/basic.html
@@ -0,0 +1,17 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width" />
+  <title>Recorder: Split View</title>
+</head>
+<body>
+  <div id="container" style="width: 90vw; height: 90vh;"></div>
+  <script src="./basic.js" type="module"></script>
+</body>
+</html>
diff --git a/front_end/ui/components/docs/recorder_split_view/basic.ts b/front_end/ui/components/docs/recorder_split_view/basic.ts
new file mode 100644
index 0000000..4ed70ef
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_split_view/basic.ts
@@ -0,0 +1,22 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../../../../panels/recorder/components/components.js';
+
+import * as ComponentHelpers from '../../../../../front_end/ui/components/helpers/helpers.js';
+import * as FrontendHelpers from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';  // eslint-disable-line rulesdir/es_modules_import
+
+await ComponentHelpers.ComponentServerSetup.setup();
+await FrontendHelpers.initializeGlobalVars();
+
+(document.getElementById('container') as HTMLElement).innerHTML = `
+    <devtools-split-view>
+        <div slot="main" style="padding: 10px;">
+            Left
+        </div>
+        <div slot="sidebar" style="padding: 10px;">
+            Sidebar
+        </div>
+    </devtools-split-view>
+`;
diff --git a/front_end/ui/components/docs/recorder_start_view/BUILD.gn b/front_end/ui/components/docs/recorder_start_view/BUILD.gn
new file mode 100644
index 0000000..09ecfb1
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_start_view/BUILD.gn
@@ -0,0 +1,23 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../scripts/build/ninja/copy.gni")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+  testonly = true
+  sources = [ "basic.ts" ]
+
+  deps = [
+    "../../../../../front_end/ui/components/helpers:bundle",
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../panels/recorder/components:bundle",
+  ]
+}
+
+copy_to_gen("recorder_start_view") {
+  testonly = true
+  sources = [ "basic.html" ]
+  deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/recorder_start_view/basic.html b/front_end/ui/components/docs/recorder_start_view/basic.html
new file mode 100644
index 0000000..9b7122f
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_start_view/basic.html
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<!DOCTYPE html>
+<html>
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width" />
+  <title>Recorder: start view</title>
+</head>
+
+<body>
+  <div id="container"></div>
+  <script src="./basic.js" type="module"></script>
+</body>
+
+</html>
diff --git a/front_end/ui/components/docs/recorder_start_view/basic.ts b/front_end/ui/components/docs/recorder_start_view/basic.ts
new file mode 100644
index 0000000..03e0f53
--- /dev/null
+++ b/front_end/ui/components/docs/recorder_start_view/basic.ts
@@ -0,0 +1,13 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as ComponentHelpers from '../../../../../front_end/ui/components/helpers/helpers.js';
+import * as FrontendHelpers from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';  // eslint-disable-line rulesdir/es_modules_import
+import * as RecorderComponents from '../../../../panels/recorder/components/components.js';
+
+await ComponentHelpers.ComponentServerSetup.setup();
+await FrontendHelpers.initializeGlobalVars();
+
+const startView = new RecorderComponents.StartView.StartView();
+document.getElementById('container')?.appendChild(startView);
diff --git a/front_end/ui/components/menus/BUILD.gn b/front_end/ui/components/menus/BUILD.gn
index 15107bb..e5e53ca 100644
--- a/front_end/ui/components/menus/BUILD.gn
+++ b/front_end/ui/components/menus/BUILD.gn
@@ -39,10 +39,5 @@
     ":menus",
   ]
 
-  visibility = [
-    "../*",
-    "../../../../test/interactions/*",
-    "../../../../test/screenshots/*",
-    "../../../../test/unittests/front_end/*",
-  ]
+  visibility = [ "*" ]
 }
diff --git a/test/e2e/BUILD.gn b/test/e2e/BUILD.gn
index 8f37900..2ae6d41 100644
--- a/test/e2e/BUILD.gn
+++ b/test/e2e/BUILD.gn
@@ -45,6 +45,7 @@
     "settings",
     "snippets",
     "sources",
+    "recorder",
     "webaudio",
   ]
 }
diff --git a/test/e2e/recorder/BUILD.gn b/test/e2e/recorder/BUILD.gn
new file mode 100644
index 0000000..48efbd7
--- /dev/null
+++ b/test/e2e/recorder/BUILD.gn
@@ -0,0 +1,21 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../third_party/typescript/typescript.gni")
+
+node_ts_library("recorder") {
+  sources = [
+    "export_test.ts",
+    "helpers.ts",
+    "recorder_test.ts",
+    "replay_test.ts",
+    "ui_test.ts",
+  ]
+
+  deps = [
+    "../../../test/e2e/helpers",
+    "../../../test/shared",
+    "../../../front_end/panels/recorder:bundle",
+  ]
+}
diff --git a/test/e2e/recorder/export_test.ts b/test/e2e/recorder/export_test.ts
new file mode 100644
index 0000000..4020c67
--- /dev/null
+++ b/test/e2e/recorder/export_test.ts
@@ -0,0 +1,113 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+
+import {
+  getBrowserAndPages,
+  waitForAria,
+  waitForFunction,
+} from '../../../test/shared/helper.js';
+import {
+  describe,
+  it,
+} from '../../../test/shared/mocha-extensions.js';
+
+import {
+  createAndStartRecording,
+  enableAndOpenRecorderPanel,
+  stopRecording,
+} from './helpers.js';
+
+describe('Recorder', function() {
+  if (this.timeout() !== 0) {
+    this.timeout(40000);
+  }
+
+  async function record() {
+    const {target, frontend} = getBrowserAndPages();
+    await target.bringToFront();
+    await frontend.bringToFront();
+    await frontend.waitForSelector('pierce/.settings');
+    await target.bringToFront();
+    const element = await target.waitForSelector('a[href="recorder2.html"]');
+    await element?.click();
+    await frontend.bringToFront();
+  }
+
+  describe('Export', () => {
+    beforeEach(async () => {
+      const {frontend} = getBrowserAndPages();
+      // Mock the extension integration part and provide a test impl using RecorderPluginManager.
+      await frontend.evaluate(`
+        (async function () {
+          const Extensions = await import('./models/extensions/extensions.js');
+          const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
+          manager.addPlugin({
+            getName() {
+              return 'TestExtension';
+            },
+            getMediaType() {
+              return 'text/javascript';
+            },
+            stringify() {
+              return Promise.resolve('stringified');
+            },
+            getCapabilities() {
+              return ['export'];
+            }
+          })
+        })();
+      `);
+      await enableAndOpenRecorderPanel('recorder/recorder.html');
+      await createAndStartRecording('Test');
+      await record();
+      await stopRecording();
+    });
+
+    const tests = [
+      ['JSON', 'Test.json', '"type": "click"'],
+      ['@puppeteer/replay', 'Test.js', '\'@puppeteer/replay\''],
+      ['Puppeteer', 'Test.js', '\'puppeteer\''],
+      ['Puppeteer (including Lighthouse analysis)', 'Test.js', '\'puppeteer\''],
+      ['TestExtension', 'Test.js', 'stringified'],
+    ];
+
+    for (const [button, filename, expectedSubstring] of tests) {
+      it(`should ${button.toLowerCase()}`, async () => {
+        const {frontend} = getBrowserAndPages();
+        const exportButton = await waitForAria('Export');
+        await exportButton.click();
+        const exportMenuItem = await waitForAria(button);
+        await frontend.evaluate(`
+          window.showSaveFilePicker = (opts) => {
+            window.__suggestedFilename = opts.suggestedName;
+            return {
+              async createWritable() {
+                let data = '';
+                return {
+                  write(part) {
+                    data += part;
+                  },
+                  close() {
+                    window.__writtenFile = data;
+                  }
+                }
+              }
+            };
+          }
+        `);
+        await exportMenuItem.click();
+        const suggestedName = await waitForFunction(async () => {
+          return await frontend.evaluate('window.__suggestedFilename');
+        });
+        const content = (await frontend.evaluate(
+                            'window.__writtenFile',
+                            )) as string;
+        assert.strictEqual(suggestedName, filename);
+        assert.isTrue(content.includes(expectedSubstring));
+      });
+    }
+  });
+});
diff --git a/test/e2e/recorder/helpers.ts b/test/e2e/recorder/helpers.ts
new file mode 100644
index 0000000..cf54a87
--- /dev/null
+++ b/test/e2e/recorder/helpers.ts
@@ -0,0 +1,310 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {type ElementHandle} from 'puppeteer';
+
+import {openPanelViaMoreTools} from '../../../test/e2e/helpers/settings-helpers.js';
+import {
+  $,
+  click,
+  clickElement,
+  getBrowserAndPages,
+  getTestServerPort,
+  goToResource,
+  timeout,
+  waitFor,
+  waitForAria,
+} from '../../../test/shared/helper.js';
+
+import {assertMatchesJSONSnapshot} from '../../../test/shared/snapshots.js';
+import {platform} from '../../../test/shared/helper.js';
+import {type UserFlow} from '../../../front_end/panels/recorder/models/Schema.js';
+import type * as Recorder from '../../../front_end/panels/recorder/recorder.js';
+
+const RECORDER_CONTROLLER_TAG_NAME = 'devtools-recorder-controller';
+const TEST_RECORDING_NAME = 'New Recording';
+const ControlOrMeta = platform === 'mac' ? 'Meta' : 'Control';
+
+export async function getRecordingController() {
+  return (await waitFor(
+             RECORDER_CONTROLLER_TAG_NAME,
+             )) as unknown as ElementHandle<Recorder.RecorderController.RecorderController>;
+}
+
+export async function onRecordingStateChanged(): Promise<unknown> {
+  const view = await getRecordingController();
+  return view.evaluate(el => {
+    return new Promise(resolve => {
+      el.addEventListener(
+          'recordingstatechanged',
+          (event: Event) => resolve(
+              (event as Recorder.RecorderEvents.RecordingStateChangedEvent).recording,
+              ),
+          {once: true},
+      );
+    });
+  });
+}
+
+export async function onRecorderAttachedToTarget(): Promise<unknown> {
+  const {frontend} = getBrowserAndPages();
+  return frontend.evaluate(() => {
+    return new Promise(resolve => {
+      window.addEventListener('recorderAttachedToTarget', resolve, {
+        once: true,
+      });
+    });
+  });
+}
+
+export async function onReplayFinished(): Promise<unknown> {
+  const view = await getRecordingController();
+  return view.evaluate(el => {
+    return new Promise(resolve => {
+      el.addEventListener('replayfinished', resolve, {once: true});
+    });
+  });
+}
+
+export async function enableUntrustedEventMode() {
+  const {frontend} = getBrowserAndPages();
+  await frontend.evaluate(() => {
+    // TODO: have an explicit UI setting or perhaps a special event to configure this
+    // instead of having a global setting.
+    // @ts-ignore
+    globalThis.Common.settings.createSetting('untrustedRecorderEvents', true);
+  });
+}
+
+export async function enableAndOpenRecorderPanel(path: string) {
+  await goToResource(path);
+  await openPanelViaMoreTools('Recorder');
+  await waitFor(RECORDER_CONTROLLER_TAG_NAME);
+}
+
+async function createRecording(name: string, selectorAttribute?: string) {
+  const newRecordingButton = await waitForAria('Create a new recording');
+  await newRecordingButton.click();
+  const input = await waitForAria('RECORDING NAME');
+  await input.type(name);
+  if (selectorAttribute) {
+    const input = await waitForAria(
+        'SELECTOR ATTRIBUTE https://g.co/devtools/recorder#selector',
+    );
+    await input.type(selectorAttribute);
+  }
+}
+
+export async function createAndStartRecording(
+    name: string,
+    selectorAttribute?: string,
+) {
+  await createRecording(name, selectorAttribute);
+  const >
+  await click('devtools-control-button');
+  await waitFor('devtools-recording-view');
+  await onRecordingStarted;
+}
+
+export async function changeNetworkConditions(condition: string) {
+  const {frontend} = getBrowserAndPages();
+  await frontend.waitForSelector('pierce/#tab-network');
+  await frontend.click('pierce/#tab-network');
+  await frontend.waitForSelector('pierce/[aria-label="Throttling"]');
+  await frontend.select('pierce/[aria-label="Throttling"] select', condition);
+}
+
+export async function openRecorderPanel() {
+  await click('[aria-label="Recorder"]');
+  await waitFor('devtools-recording-view');
+}
+
+type StartRecordingOptions = {
+  networkCondition?: string,
+  untrustedEvents?: boolean,
+  selectorAttribute?: string,
+};
+
+export async function startRecording(
+    path: string,
+    options: StartRecordingOptions = {
+      networkCondition: '',
+      untrustedEvents: false,
+    },
+) {
+  if (options.networkCondition) {
+    await changeNetworkConditions(options.networkCondition);
+  }
+  await enableAndOpenRecorderPanel(path);
+  if (options.untrustedEvents) {
+    await enableUntrustedEventMode();
+  }
+  await createAndStartRecording(TEST_RECORDING_NAME, options.selectorAttribute);
+}
+
+export async function stopRecording(): Promise<unknown> {
+  const {frontend} = getBrowserAndPages();
+  await frontend.bringToFront();
+  const >
+  await click('aria/End recording');
+  return await onRecordingStopped;
+}
+
+interface RecordingSnapshotOptions {
+  /**
+   * Whether to keep the offsets for recording or not.
+   *
+   * @defaultValue `false`
+   */
+  offsets?: boolean;
+}
+
+const preprocessRecording = (
+    recording: unknown,
+    options: RecordingSnapshotOptions = {},
+    ): unknown => {
+  let value = JSON.stringify(recording).replaceAll(
+      `:${getTestServerPort()}`,
+      ':<test-port>',
+  );
+  value = value.replaceAll('\u200b', '');
+  if (!options.offsets) {
+    value = value.replaceAll(
+        /,?"(?:offsetY|offsetX)":[0-9]+(?:\.[0-9]+)?/g,
+        '',
+    );
+  }
+  return JSON.parse(value.trim());
+};
+
+export const assertRecordingMatchesSnapshot = (
+    recording: unknown,
+    options: RecordingSnapshotOptions = {},
+    ) => {
+  assertMatchesJSONSnapshot(preprocessRecording(recording, options));
+};
+
+async function setCode(flow: string) {
+  const view = await getRecordingController();
+  await view.evaluate((el, flow) => {
+    el.dispatchEvent(new CustomEvent('setrecording', {detail: flow}));
+  }, flow);
+}
+
+async function waitForDialogAnimationEnd(root?: ElementHandle) {
+  const ANIMATION_TIMEOUT = 2000;
+  const dialog = await waitFor('dialog[open]', root);
+  const animationPromise = dialog.evaluate((dialog: Element) => {
+    return new Promise<void>(resolve => {
+      dialog.addEventListener('animationend', () => resolve(), {once: true});
+    });
+  });
+  await Promise.race([animationPromise, timeout(ANIMATION_TIMEOUT)]);
+}
+
+export async function clickSelectButtonItem(itemLabel: string, root: string) {
+  const selectMenu = await waitFor(root);
+  const selectMenuButton = await waitFor(
+      'devtools-select-menu-button',
+      selectMenu,
+  );
+  const selectMenuButtonArrow = await waitFor('#arrow', selectMenuButton);
+  const animationEndPromise = waitForDialogAnimationEnd();
+  await clickElement(selectMenuButtonArrow);
+  await animationEndPromise;
+
+  const selectMenuItems = await selectMenu.$$('pierce/devtools-menu-item');
+  const selectMenuItemIndex =
+      await Promise
+          .all(
+              selectMenuItems.map(
+                  selectMenuItem => selectMenuItem.evaluate(element => element.textContent?.trim()),
+                  ),
+              )
+          .then(
+              elements => elements.findIndex(elementText => elementText === itemLabel),
+          );
+
+  if (selectMenuItemIndex === -1) {
+    throw new Error(
+        `Select menu item for label "${itemLabel}" is not found in "${root}"`,
+    );
+  }
+
+  await clickElement(selectMenuItems[selectMenuItemIndex]);
+}
+
+export async function setupRecorderWithScript(
+    script: UserFlow,
+    path: string = 'recorder/recorder.html',
+    ): Promise<void> {
+  await enableAndOpenRecorderPanel(path);
+  await createAndStartRecording(script.title);
+  await stopRecording();
+  await setCode(JSON.stringify(script));
+}
+
+export async function setupRecorderWithScriptAndReplay(
+    script: UserFlow,
+    path: string = 'recorder/recorder.html',
+    ): Promise<void> {
+  await setupRecorderWithScript(script, path);
+  const >
+  await clickSelectButtonItem('Normal (Default)', 'devtools-replay-button');
+  await onceFinished;
+}
+
+export async function getCurrentRecording(): Promise<unknown> {
+  const controller = await $(RECORDER_CONTROLLER_TAG_NAME);
+  const recording = (await controller?.evaluate(
+                        el => JSON.stringify((el as unknown as {getUserFlow(): unknown}).getUserFlow()),
+                        )) as string;
+  return JSON.parse(recording);
+}
+
+export async function startOrStopRecordingShortcut(
+    execute: 'page'|'frontend' = 'frontend',
+) {
+  const {target, frontend} = getBrowserAndPages();
+  const executeOn = execute === 'frontend' ? frontend : target;
+  const >
+  await executeOn.bringToFront();
+  await executeOn.keyboard.down(ControlOrMeta);
+  await executeOn.keyboard.down('e');
+  await executeOn.keyboard.up(ControlOrMeta);
+  await executeOn.keyboard.up('e');
+
+  await waitFor('devtools-recording-view');
+  return await onRecordingStarted;
+}
+
+export async function fillCreateRecordingForm(path: string) {
+  await enableAndOpenRecorderPanel(path);
+  await createRecording(TEST_RECORDING_NAME);
+}
+
+export async function startRecordingViaShortcut(path: string) {
+  await enableAndOpenRecorderPanel(path);
+  await startOrStopRecordingShortcut();
+}
+
+export async function replayShortcut() {
+  const {frontend} = getBrowserAndPages();
+  await frontend.bringToFront();
+  await frontend.keyboard.down(ControlOrMeta);
+  await frontend.keyboard.down('Enter');
+  await frontend.keyboard.up(ControlOrMeta);
+  await frontend.keyboard.up('Enter');
+}
+
+export async function toggleCodeView() {
+  const {frontend} = getBrowserAndPages();
+  await frontend.bringToFront();
+  await frontend.keyboard.down(ControlOrMeta);
+  await frontend.keyboard.down('b');
+  await frontend.keyboard.up(ControlOrMeta);
+  await frontend.keyboard.up('b');
+}
diff --git a/test/e2e/recorder/recorder_test.ts b/test/e2e/recorder/recorder_test.ts
new file mode 100644
index 0000000..5cc338a
--- /dev/null
+++ b/test/e2e/recorder/recorder_test.ts
@@ -0,0 +1,673 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {assert} from 'chai';
+
+import {
+  assertNotNullOrUndefined,
+  getBrowserAndPages,
+  renderCoordinatorQueueEmpty,
+  waitForAria,
+  waitForFunction,
+} from '../../../test/shared/helper.js';
+import {
+  describe,
+  it,
+} from '../../../test/shared/mocha-extensions.js';
+import {
+  changeNetworkConditions,
+  fillCreateRecordingForm,
+  getCurrentRecording,
+  openRecorderPanel,
+  startRecording,
+  startRecordingViaShortcut,
+  stopRecording,
+  startOrStopRecordingShortcut,
+  getRecordingController,
+  onRecorderAttachedToTarget,
+  assertRecordingMatchesSnapshot,
+} from './helpers.js';
+import {type RecorderActions} from '../../../front_end/panels/recorder/recorder-actions.js';
+import {type UserFlow} from '../../../front_end/panels/recorder/models/Schema.js';
+import {assertMatchesJSONSnapshot} from '../../../test/shared/snapshots.js';
+import {type StepChanged} from '../../../front_end/panels/recorder/components/StepView.js';
+
+describe('Recorder', function() {
+  if (this.timeout() !== 0) {
+    this.timeout(5000);
+  }
+
+  it('should capture the initial page as the url of the first section', async () => {
+    await startRecording('recorder/recorder.html');
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture clicks on buttons', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#test');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture multiple clicks with duration', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+
+    const element = await target.waitForSelector('#test');
+    assertNotNullOrUndefined(element);
+
+    const point = await element.clickablePoint();
+    await target.mouse.move(point.x, point.y);
+
+    await target.mouse.down();
+    await new Promise(resolve => setTimeout(resolve, 350));
+    await target.mouse.up();
+
+    await target.mouse.down();
+    await new Promise(resolve => setTimeout(resolve, 350));
+    await target.mouse.up();
+
+    const recording = await stopRecording();
+    const steps = (recording as UserFlow).steps.slice(2);
+    assert.strictEqual(steps.length, 2);
+    for (const step of steps) {
+      assertNotNullOrUndefined(step);
+
+      assert.strictEqual(step.type, 'click');
+      assert.isTrue('duration' in step && step.duration && step.duration > 350);
+    }
+  });
+
+  it('should capture non-primary clicks and double clicks', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#mouse-button', {button: 'middle'});
+    await target.click('#mouse-button', {button: 'right'});
+    await target.click('#mouse-button', {button: 'forward' as 'left'});
+    await target.click('#mouse-button', {button: 'back' as 'left'});
+    await target.click('#mouse-button', {clickCount: 1});
+    await target.click('#mouse-button', {clickCount: 2});
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture clicks on input buttons', async () => {
+    await startRecording('recorder/input.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#reset');
+    await target.click('#submit');
+    await target.click('#button');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture clicks on buttons with custom selector attribute', async () => {
+    await startRecording('recorder/recorder.html', {
+      selectorAttribute: 'data-devtools-test',
+    });
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#selector-attribute');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture Enter key presses on buttons', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    const button = await target.waitForSelector('#test');
+    await button?.press('Enter');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should not capture synthetic events', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#synthetic');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture implicit form submissions', async () => {
+    await startRecording('recorder/form.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#name');
+    await target.type('#name', 'test');
+    await target.keyboard.down('Enter');
+    await target.waitForFunction(() => {
+      return window.location.href.endsWith('form.html?name=test');
+    });
+    await target.keyboard.up('Enter');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture clicks on submit buttons inside of forms as click steps', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#form-button');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should build an ARIA selector for the parent element that is interactive', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#span');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should fall back to a css selector if an element does not have an accessible and interactive parent',
+     async () => {
+       await startRecording('recorder/recorder.html');
+
+       const {target} = getBrowserAndPages();
+       await target.bringToFront();
+       await target.click('#span2');
+
+       const recording = await stopRecording();
+       assertRecordingMatchesSnapshot(recording);
+     });
+
+  it('should create an aria selector even if the element is within a shadow root', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('pierce/#inner-span');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should record clicks on shadow DOM elements with slots containing text nodes only', async () => {
+    await startRecording('recorder/shadow-text-node.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('custom-button');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should record interactions with elements within iframes', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.mainFrame().childFrames()[0].click('#in-iframe');
+    await target.mainFrame().childFrames()[0].childFrames()[0].click('aria/Inner iframe button');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should wait for navigations in the generated scripts', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('aria/Page 2');
+    await target.waitForFunction(() => {
+      return window.location.href.endsWith('recorder2.html');
+    });
+    await target.waitForSelector('aria/Back to Page 1');
+    await waitForFunction(async () => {
+      const recording = await getCurrentRecording();
+      return (recording as {steps: unknown[]}).steps.length >= 3;
+    });
+    await target.click('aria/Back to Page 1');
+    await target.waitForFunction(() => {
+      return window.location.href.endsWith('recorder.html');
+    });
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  // TODO: remove flakiness from recording network conditions.
+  it.skip('[crbug.com/1224832]: should also record network conditions', async () => {
+    await startRecording('recorder/recorder.html', {
+      networkCondition: 'Fast 3G',
+    });
+
+    const {frontend, target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#test');
+    await frontend.bringToFront();
+    await changeNetworkConditions('Slow 3G');
+    await openRecorderPanel();
+    await target.bringToFront();
+    await target.click('#test');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture keyboard events on inputs', async () => {
+    await startRecording('recorder/input.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.keyboard.press('Tab');
+    await target.keyboard.type('1');
+    await target.keyboard.press('Tab');
+    await target.keyboard.type('2');
+    // TODO(alexrudenko): for some reason the headless test does not flush the buffer
+    // when recording is stopped.
+    await target.evaluate(
+        () => (document.querySelector('#two') as HTMLElement).blur(),
+    );
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture keyboard events on non-text inputs', async () => {
+    await startRecording('recorder/input.html', {untrustedEvents: true});
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    const color = await target.waitForSelector('#color');
+    assertNotNullOrUndefined(color);
+    await color.click();
+
+    // Imitating an input event.
+    await color.evaluate(el => {
+      const element = el as HTMLInputElement;
+      element.value = '#333333';
+      element.dispatchEvent(new Event('input', {bubbles: true}));
+    });
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture navigation without change', async () => {
+    await startRecording('recorder/input.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.keyboard.press('Tab');
+    await target.keyboard.down('Shift');
+    await target.keyboard.press('Tab');
+    await target.keyboard.up('Shift');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture a change that causes navigation without blur or change', async () => {
+    await startRecording('recorder/programmatic-navigation-on-keydown.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.keyboard.press('1');
+    await target.keyboard.press('Enter', {delay: 50});
+
+    await waitForFunction(async () => {
+      const controller = await getRecordingController();
+      return controller.evaluate(
+          c => c.getCurrentRecordingForTesting()?.flow.steps.length === 5,
+      );
+    });
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should associate events with right navigations', async () => {
+    await startRecording('recorder/multiple-navigations.html');
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('button');
+    await target.waitForFunction(() => {
+      return window.location.href.endsWith('input.html');
+    });
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should work for select elements', async () => {
+    await startRecording('recorder/select.html', {untrustedEvents: true});
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#select');
+    await target.select('#select', 'O2');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should work for checkbox elements', async () => {
+    await startRecording('recorder/checkbox.html', {untrustedEvents: true});
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#checkbox');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should work for elements modified on mousedown', async () => {
+    await startRecording('recorder/input.html', {untrustedEvents: true});
+
+    const {target} = getBrowserAndPages();
+    await target.bringToFront();
+    await target.click('#to-be-modified');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should record OOPIF interactions', async () => {
+    const {target} = getBrowserAndPages();
+    await startRecording('recorder/oopif.html', {untrustedEvents: true});
+
+    await target.bringToFront();
+    const frame = target.frames().find(frame => frame.url().endsWith('iframe1.html'));
+    const link = await frame?.waitForSelector('aria/To iframe 2');
+    const frame2Promise = target.waitForFrame(
+        frame => frame.url().endsWith('iframe2.html'),
+    );
+    await link?.click();
+    const frame2 = await frame2Promise;
+    await frame2?.waitForSelector('aria/To iframe 1');
+    // Preventive timeout because apparently out-of-process targets might trigger late events that
+    // cause handled errors in DevTools.
+    await new Promise(resolve => setTimeout(resolve, 250));
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should capture and store screenshots for every section', async () => {
+    const {frontend} = getBrowserAndPages();
+    await startRecording('recorder/recorder.html');
+    await stopRecording();
+    const screenshot = await frontend.waitForSelector(
+        'pierce/.section .screenshot',
+    );
+    assert.isTrue(Boolean(screenshot));
+  });
+
+  it('should record interactions with popups', async () => {
+    await startRecording('recorder/recorder.html', {untrustedEvents: true});
+
+    const {target, browser} = getBrowserAndPages();
+    await target.bringToFront();
+    const openPopupButton = await target.waitForSelector('aria/Open Popup');
+    // Popups are separate targets so Recorder is only able to learn about them
+    // after a while. To allow no-flaky testing, we need to synchronise with the
+    // frontend here.
+    const recorderHandledPopup = onRecorderAttachedToTarget();
+    await openPopupButton?.click();
+    await waitForFunction(async () => {
+      const controller = await getRecordingController();
+      return controller.evaluate(c => {
+        const steps = c.getCurrentRecordingForTesting()?.flow.steps;
+        return steps?.length === 3 && steps[1].assertedEvents?.length === 1;
+      });
+    });
+
+    const popupTarget = await browser.waitForTarget(
+        target => target.url().endsWith('popup.html'),
+    );
+    const popupPage = await popupTarget.page();
+    await popupPage?.bringToFront();
+
+    await recorderHandledPopup;
+    const buttonInPopup = await popupPage?.waitForSelector(
+        'aria/Button in Popup',
+    );
+    await buttonInPopup?.click();
+    await waitForFunction(async () => {
+      const controller = await getRecordingController();
+      return controller.evaluate(
+          c => c.getCurrentRecordingForTesting()?.flow.steps.length === 4,
+      );
+    });
+    await popupPage?.close();
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should break out shifts in text controls', async () => {
+    await startRecording('recorder/input.html');
+
+    const {target} = getBrowserAndPages();
+
+    await target.bringToFront();
+    await target.keyboard.press('Tab');
+    await target.keyboard.type('1');
+    await target.keyboard.press('Shift');
+    await target.keyboard.type('d');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should work with contiguous inputs', async () => {
+    await startRecording('recorder/input.html');
+
+    const {target} = getBrowserAndPages();
+
+    await target.bringToFront();
+
+    // Focus the first input in the contiguous line of inputs.
+    await target.waitForSelector('#contiguous-field-1');
+    await target.focus('#contiguous-field-1');
+
+    // This should type into `#contiguous-field-1` and `#contiguous-field-2` due
+    // to the in-page script.
+    await target.keyboard.type('somethingworks');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should work with shadow inputs', async () => {
+    await startRecording('recorder/shadow-input.html');
+
+    const {target} = getBrowserAndPages();
+
+    await target.bringToFront();
+    await target.click('custom-input');
+    await target.keyboard.type('works');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should edit while recording', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target, frontend} = getBrowserAndPages();
+    await frontend.bringToFront();
+
+    const steps = await waitForFunction(async () => {
+      const steps = await frontend.$$('pierce/devtools-step-view');
+      return steps.length === 3 ? steps : undefined;
+    });
+    const lastStep = steps.pop();
+
+    if (!lastStep) {
+      throw new Error('Step is not found.');
+    }
+
+    await lastStep.click({button: 'right'});
+
+    const removeStep = await waitForAria('Remove step[role="menuitem"]');
+    await removeStep.click();
+
+    await target.bringToFront();
+    await target.click('#test');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should edit the type while recording', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {target, frontend} = getBrowserAndPages();
+
+    await target.bringToFront();
+    await target.click('#test');
+
+    await frontend.bringToFront();
+    const steps = await waitForFunction(async () => {
+      const steps = await frontend.$$('pierce/devtools-step-view');
+      return steps.length === 5 ? steps : undefined;
+    });
+    const step = steps.pop();
+    assertNotNullOrUndefined(step);
+    const title = await step.waitForSelector(':scope >>>> .main-title');
+    assertNotNullOrUndefined(title);
+    await title.click();
+
+    const input = await step.waitForSelector(
+        ':scope >>>> devtools-recorder-step-editor >>>> div:nth-of-type(1) > devtools-recorder-input');
+    assertNotNullOrUndefined(input);
+    await input.focus();
+
+    const eventPromise = step.evaluate(element => {
+      return new Promise(resolve => {
+        element.addEventListener('stepchanged', (event: Event) => {
+          resolve((event as StepChanged).newStep);
+        }, {once: true});
+      });
+    });
+
+    await frontend.keyboard.type('emulateNetworkConditions');
+    await frontend.keyboard.press('Enter');
+
+    assertMatchesJSONSnapshot(await eventPromise);
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  it('should add an assertion through the button', async () => {
+    await startRecording('recorder/recorder.html');
+
+    const {frontend} = getBrowserAndPages();
+    await frontend.bringToFront();
+
+    // Find the button.
+    const button = await waitForFunction(async () => {
+      return frontend.$('pierce/.add-assertion-button');
+    });
+    if (!button) {
+      throw new Error('Add assertion button not found.');
+    }
+
+    // Add an assertion.
+    await button.click();
+    await renderCoordinatorQueueEmpty();
+
+    // Get the latest step.
+    const step = await frontend.$('pierce/.section:last-child devtools-step-view:last-of-type');
+    if (!step) {
+      throw new Error('Could not find step.');
+    }
+
+    // Check that it's expanded.
+    if (!(await step.$('pierce/devtools-timeline-section.expanded'))) {
+      throw new Error('Last step is not open.');
+    }
+
+    // Check that it's the correct step.
+    assert.strictEqual(await step.$eval('pierce/.main-title', element => element.textContent), 'Wait for element');
+
+    const recording = await stopRecording();
+    assertRecordingMatchesSnapshot(recording);
+  });
+
+  describe('Shortcuts', () => {
+    it('should not open create a new recording while recording', async () => {
+      await startRecordingViaShortcut('recorder/recorder.html');
+      const controller = await getRecordingController();
+      await controller.evaluate(element => {
+        return element.handleActions(
+            'chrome_recorder.create-recording' as RecorderActions.CreateRecording,
+        );
+      });
+      const page = await controller.evaluate(element => {
+        return element.getCurrentPageForTesting();
+      });
+
+      assert.isTrue(page !== 'CreateRecordingPage');
+
+      await stopRecording();
+    });
+
+    it('should start with keyboard shortcut while on the create page', async () => {
+      await fillCreateRecordingForm('recorder/recorder.html');
+      await startOrStopRecordingShortcut();
+      const recording = (await stopRecording()) as UserFlow;
+      assertRecordingMatchesSnapshot(recording);
+    });
+
+    it('should stop with keyboard shortcut without recording it', async () => {
+      await startRecordingViaShortcut('recorder/recorder.html');
+      const recording = (await startOrStopRecordingShortcut()) as UserFlow;
+      assertRecordingMatchesSnapshot({...recording, title: 'Test recording'});
+    });
+
+    it('should stop recording with shortcut on the target', async () => {
+      await startRecording('recorder/recorder.html');
+
+      const {target} = getBrowserAndPages();
+      await target.bringToFront();
+      await target.keyboard.down('e');
+      await target.keyboard.up('e');
+
+      const recording = (await startOrStopRecordingShortcut(
+                            'page',
+                            )) as UserFlow;
+      assertRecordingMatchesSnapshot(recording);
+    });
+  });
+});
diff --git a/test/e2e/recorder/replay_test.ts b/test/e2e/recorder/replay_test.ts
new file mode 100644
index 0000000..56541fc
--- /dev/null
+++ b/test/e2e/recorder/replay_test.ts
@@ -0,0 +1,679 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {assert} from 'chai';
+
+import {
+  type StepType,
+  type AssertedEventType,
+} from '../../../front_end/panels/recorder/models/Schema.js';
+import {
+  getBrowserAndPages,
+  getResourcesPath,
+  getTestServerPort,
+  waitFor,
+  click,
+} from '../../../test/shared/helper.js';
+import {
+  describe,
+  it,
+} from '../../../test/shared/mocha-extensions.js';
+
+import {
+  clickSelectButtonItem,
+  onReplayFinished,
+  replayShortcut,
+  setupRecorderWithScript,
+  setupRecorderWithScriptAndReplay,
+} from './helpers.js';
+
+describe('Recorder', function() {
+  if (this.timeout() !== 0) {
+    this.timeout(40000);
+  }
+
+  describe('Replay', () => {
+    it('should navigate to the url of the first section', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/recorder2.html`,
+          },
+        ],
+      });
+      assert.strictEqual(
+          target.url(),
+          `${getResourcesPath()}/recorder/recorder2.html`,
+      );
+    });
+
+    it('should be able to replay click steps', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/recorder.html`,
+          },
+          {
+            type: 'click' as StepType.Click,
+            selectors: ['a[href="recorder2.html"]'],
+            offsetX: 1,
+            offsetY: 1,
+            assertedEvents: [
+              {
+                type: 'navigation' as AssertedEventType.Navigation,
+                url: `${getResourcesPath()}/recorder/recorder.html`,
+              },
+            ],
+          },
+        ],
+      });
+      assert.strictEqual(
+          target.url(),
+          `${getResourcesPath()}/recorder/recorder2.html`,
+      );
+    });
+
+    it('should be able to replay click steps on checkboxes', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/checkbox.html`,
+          },
+          {
+            type: 'click' as StepType.Click,
+            selectors: ['input'],
+            offsetX: 1,
+            offsetY: 1,
+          },
+        ],
+      });
+      assert.strictEqual(
+          await target.evaluate(() => document.querySelector('input')?.checked),
+          true,
+      );
+    });
+
+    it('should be able to replay keyboard events', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/input.html`,
+          },
+          {type: 'keyDown' as StepType.KeyDown, target: 'main', key: 'Tab'},
+          {type: 'keyUp' as StepType.KeyUp, target: 'main', key: 'Tab'},
+          {type: 'keyDown' as StepType.KeyDown, target: 'main', key: '1'},
+          {type: 'keyUp' as StepType.KeyUp, target: 'main', key: '1'},
+          {type: 'keyDown' as StepType.KeyDown, target: 'main', key: 'Tab'},
+          {type: 'keyUp' as StepType.KeyUp, target: 'main', key: 'Tab'},
+          {type: 'keyDown' as StepType.KeyDown, target: 'main', key: '2'},
+          {type: 'keyUp' as StepType.KeyUp, target: 'main', key: '2'},
+        ],
+      });
+      const value = await target.$eval(
+          '#log',
+          e => (e as HTMLElement).innerText.trim(),
+      );
+      assert.strictEqual(value, ['one:1', 'two:2'].join('\n'));
+    });
+
+    it('should be able to replay events on select', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/select.html`,
+          },
+          {
+            type: 'change' as StepType.Change,
+            target: 'main',
+            selectors: ['aria/Select'],
+            value: 'O2',
+          },
+        ],
+      });
+
+      const value = await target.$eval(
+          '#select',
+          e => (e as HTMLSelectElement).value,
+      );
+      assert.strictEqual(value, 'O2');
+    });
+
+    it('should be able to replay events on non text inputs', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/input.html`,
+          },
+          {
+            type: 'change' as StepType.Change,
+            target: 'main',
+            selectors: ['#color'],
+            value: '#333333',
+          },
+        ],
+      });
+
+      const value = await target.$eval(
+          '#color',
+          e => (e as HTMLSelectElement).value,
+      );
+      assert.strictEqual(value, '#333333');
+    });
+
+    it('should be able to replay events with text selectors', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/iframe1.html`,
+          },
+          {
+            type: 'click' as StepType.Click,
+            target: 'main',
+            selectors: ['text/To'],
+            offsetX: 0,
+            offsetY: 0,
+          },
+        ],
+      });
+
+      const frame = target.frames().find(
+          frame => frame.url() === `${getResourcesPath()}/recorder/iframe2.html`,
+      );
+      assert.ok(frame, 'Frame that the target page navigated to is not found');
+    });
+
+    it('should be able to replay events with xpath selectors', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/iframe1.html`,
+          },
+          {
+            type: 'click' as StepType.Click,
+            target: 'main',
+            selectors: ['xpath//html/body/a'],
+            offsetX: 0,
+            offsetY: 0,
+          },
+        ],
+      });
+
+      const frame = target.frames().find(
+          frame => frame.url() === `${getResourcesPath()}/recorder/iframe2.html`,
+      );
+      assert.ok(frame, 'Frame that the target page navigated to is not found');
+    });
+
+    it('should be able to override the value in text inputs that have a value already', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/input.html`,
+          },
+          {
+            type: 'change' as StepType.Change,
+            target: 'main',
+            selectors: ['#prefilled'],
+            value: 'cba',
+          },
+        ],
+      });
+
+      const value = await target.$eval(
+          '#prefilled',
+          e => (e as HTMLSelectElement).value,
+      );
+      assert.strictEqual(value, 'cba');
+    });
+
+    it('should be able to override the value in text inputs that are partially prefilled', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/input.html`,
+          },
+          {
+            type: 'change' as StepType.Change,
+            target: 'main',
+            selectors: ['#partially-prefilled'],
+            value: 'abcdef',
+          },
+        ],
+      });
+
+      const value = await target.$eval(
+          '#partially-prefilled',
+          e => (e as HTMLSelectElement).value,
+      );
+      assert.strictEqual(value, 'abcdef');
+    });
+
+    it('should be able to replay viewport change', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/select.html`,
+          },
+          {
+            type: 'setViewport' as StepType.SetViewport,
+            width: 800,
+            height: 600,
+            isLandscape: false,
+            isMobile: false,
+            deviceScaleFactor: 1,
+            hasTouch: false,
+          },
+        ],
+      });
+
+      assert.strictEqual(
+          await target.evaluate(() => window.visualViewport?.width),
+          800,
+      );
+      assert.strictEqual(
+          await target.evaluate(() => window.visualViewport?.height),
+          600,
+      );
+    });
+
+    it('should be able to replay scroll events', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/scroll.html`,
+          },
+          {
+            type: 'setViewport' as StepType.SetViewport,
+            width: 800,
+            height: 600,
+            isLandscape: false,
+            isMobile: false,
+            deviceScaleFactor: 1,
+            hasTouch: false,
+          },
+          {
+            type: 'scroll' as StepType.Scroll,
+            target: 'main',
+            selectors: ['body > div:nth-child(1)'],
+            x: 0,
+            y: 40,
+          },
+          {type: 'scroll' as StepType.Scroll, target: 'main', x: 40, y: 40},
+        ],
+      });
+
+      assert.strictEqual(await target.evaluate(() => window.pageXOffset), 40);
+      assert.strictEqual(await target.evaluate(() => window.pageYOffset), 40);
+      assert.strictEqual(
+          await target.evaluate(
+              () => document.querySelector('#overflow')?.scrollTop,
+              ),
+          40,
+      );
+      assert.strictEqual(
+          await target.evaluate(
+              () => document.querySelector('#overflow')?.scrollLeft,
+              ),
+          0,
+      );
+    });
+
+    it('should be able to scroll into view when needed', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'setViewport' as StepType.SetViewport,
+            width: 800,
+            height: 600,
+            isLandscape: false,
+            isMobile: false,
+            deviceScaleFactor: 1,
+            hasTouch: false,
+          },
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/scroll-into-view.html`,
+          },
+          {
+            type: 'click' as StepType.Click,
+            selectors: [['button']],
+            offsetX: 1,
+            offsetY: 1,
+          },
+        ],
+      });
+      assert.strictEqual(
+          await target.evaluate(
+              () => document.querySelector('button')?.innerText,
+              ),
+          'clicked',
+      );
+    });
+
+    it('should be able to replay ARIA selectors on inputs', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/form.html`,
+          },
+          {
+            type: 'setViewport' as StepType.SetViewport,
+            width: 800,
+            height: 600,
+            isLandscape: false,
+            isMobile: false,
+            deviceScaleFactor: 1,
+            hasTouch: false,
+          },
+          {
+            type: 'click' as StepType.Click,
+            target: 'main',
+            selectors: ['aria/Name:'],
+            offsetX: 1,
+            offsetY: 1,
+          },
+        ],
+      });
+
+      assert.strictEqual(
+          await target.evaluate(() => document.activeElement?.id),
+          'name',
+      );
+    });
+
+    it('should be able to waitForElement', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/shadow-dynamic.html`,
+          },
+          {
+            type: 'waitForElement' as StepType.WaitForElement,
+            selectors: [['custom-element', 'button']],
+          },
+          {
+            type: 'click' as StepType.Click,
+            target: 'main',
+            selectors: [['custom-element', 'button']],
+            offsetX: 1,
+            offsetY: 1,
+          },
+          {
+            type: 'waitForElement' as StepType.WaitForElement,
+            selectors: [['custom-element', 'button']],
+            operator: '>=',
+            count: 2,
+          },
+        ],
+      });
+      assert.strictEqual(
+          await target.evaluate(
+              () => document.querySelectorAll('custom-element').length,
+              ),
+          2,
+      );
+    });
+
+    it('should be able to waitForExpression', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/shadow-dynamic.html`,
+          },
+          {
+            type: 'click' as StepType.Click,
+            target: 'main',
+            selectors: [['custom-element', 'button']],
+            offsetX: 1,
+            offsetY: 1,
+          },
+          {
+            type: 'waitForExpression' as StepType.WaitForExpression,
+            target: 'main',
+            expression: 'document.querySelectorAll("custom-element").length === 2',
+          },
+        ],
+      });
+      assert.strictEqual(
+          await target.evaluate(
+              () => document.querySelectorAll('custom-element').length,
+              ),
+          2,
+      );
+    });
+
+    it('should show PerformancePanel if the MeasurePerformance SelectMenu is clicked for replay', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScript({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/recorder2.html`,
+          },
+        ],
+      });
+      const >
+      await click('aria/Performance panel');
+      await onceFinished;
+      assert.strictEqual(
+          target.url(),
+          `${getResourcesPath()}/recorder/recorder2.html`,
+      );
+      await waitFor('[aria-label="Performance panel"]');
+    });
+
+    it('should be able to replay actions with popups', async () => {
+      const {browser} = getBrowserAndPages();
+      const events: Array<{type: string, url: string}> = [];
+      // We can't import 'puppeteer' here because its not listed in the tsconfig.json of
+      // the test target.
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const targetLifecycleHandler = (target: any, type: string) => {
+        if (!target.url().endsWith('popup.html')) {
+          return;
+        }
+        events.push({type, url: target.url()});
+      };
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const targetCreatedHandler = (target: any) => targetLifecycleHandler(target, 'targetCreated');
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const targetDestroyedHandler = (target: any) => targetLifecycleHandler(target, 'targetDestroyed');
+
+      browser.on('targetcreated', targetCreatedHandler);
+      browser.on('targetdestroyed', targetDestroyedHandler);
+
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/recorder.html`,
+            assertedEvents: [
+              {
+                title: '',
+                type: 'navigation' as AssertedEventType.Navigation,
+                url: 'https://<url>/test/e2e/resources/recorder/recorder.html',
+              },
+            ],
+          },
+          {
+            type: 'click' as StepType.Click,
+            selectors: [['aria/Open Popup'], ['#popup']],
+            target: 'main',
+            offsetX: 1,
+            offsetY: 1,
+          },
+          {
+            type: 'click' as StepType.Click,
+            selectors: [['aria/Button in Popup'], ['body > button']],
+            target: `${getResourcesPath()}/recorder/popup.html`,
+            offsetX: 1,
+            offsetY: 1,
+          },
+          {
+            type: 'close' as StepType.Close,
+            target: `${getResourcesPath()}/recorder/popup.html`,
+          },
+        ],
+      });
+      assert.deepEqual(events, [
+        {
+          type: 'targetCreated',
+          url: `${getResourcesPath()}/recorder/popup.html`,
+        },
+        {
+          type: 'targetDestroyed',
+          url: `${getResourcesPath()}/recorder/popup.html`,
+        },
+      ]);
+
+      browser.off('targetcreated', targetCreatedHandler);
+      browser.off('targetdestroyed', targetDestroyedHandler);
+    });
+
+    it('should record interactions with OOPIFs', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScriptAndReplay({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `https://localhost:${getTestServerPort()}/test/e2e/resources/recorder/oopif.html`,
+            assertedEvents: [
+              {
+                title: '',
+                type: 'navigation' as AssertedEventType.Navigation,
+                url: `https://localhost:${getTestServerPort()}/test/e2e/resources/recorder/oopif.html`,
+              },
+            ],
+          },
+          {
+            type: 'click' as StepType.Click,
+            target: `https://devtools.oopif.test:${getTestServerPort()}/test/e2e/resources/recorder/iframe1.html`,
+            selectors: [['aria/To iframe 2'], ['body > a']],
+            offsetX: 1,
+            offsetY: 1,
+            assertedEvents: [
+              {
+                type: 'navigation' as AssertedEventType.Navigation,
+                title: '',
+                url: `https://devtools.oopif.test:${getTestServerPort()}/test/e2e/resources/recorder/iframe2.html`,
+              },
+            ],
+          },
+        ],
+      });
+      const frame = target.frames().find(
+          frame => frame.url() ===
+              `https://devtools.oopif.test:${getTestServerPort()}/test/e2e/resources/recorder/iframe2.html`,
+      );
+      assert.ok(frame, 'Frame that the target page navigated to is not found');
+    });
+
+    it('should replay when clicked on slow replay', async () => {
+      const {target} = getBrowserAndPages();
+      await setupRecorderWithScript({
+        title: 'Test Recording',
+        steps: [
+          {
+            type: 'navigate' as StepType.Navigate,
+            url: `${getResourcesPath()}/recorder/recorder.html`,
+          },
+          {
+            type: 'click' as StepType.Click,
+            selectors: ['a[href="recorder2.html"]'],
+            offsetX: 1,
+            offsetY: 1,
+            assertedEvents: [
+              {
+                type: 'navigation' as AssertedEventType.Navigation,
+                url: `${getResourcesPath()}/recorder/recorder.html`,
+              },
+            ],
+          },
+        ],
+      });
+
+      const >
+      await clickSelectButtonItem('Slow', 'devtools-replay-button');
+      await onceFinished;
+
+      assert.strictEqual(
+          target.url(),
+          `${getResourcesPath()}/recorder/recorder2.html`,
+      );
+    });
+  });
+
+  it('should be able to start a replay with shortcut', async () => {
+    const {target} = getBrowserAndPages();
+    await setupRecorderWithScript({
+      title: 'Test Recording',
+      steps: [
+        {
+          type: 'navigate' as StepType.Navigate,
+          url: `${getResourcesPath()}/recorder/recorder2.html`,
+        },
+      ],
+    });
+    const >
+    await replayShortcut();
+    await onceFinished;
+
+    assert.strictEqual(
+        target.url(),
+        `${getResourcesPath()}/recorder/recorder2.html`,
+    );
+  });
+});
diff --git a/test/e2e/recorder/ui_test.ts b/test/e2e/recorder/ui_test.ts
new file mode 100644
index 0000000..a8bb484
--- /dev/null
+++ b/test/e2e/recorder/ui_test.ts
@@ -0,0 +1,359 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {assert} from 'chai';
+import {type ElementHandle, type Page} from 'puppeteer';
+
+import {type StepType} from '../../../front_end/panels/recorder/models/Schema.js';
+
+import {
+  getBrowserAndPages,
+  getResourcesPath,
+  waitFor,
+  waitForAria,
+  waitForFunction,
+  $$,
+  waitForNone,
+  click,
+  waitForAnimationFrame,
+  renderCoordinatorQueueEmpty,
+  assertNotNullOrUndefined,
+} from '../../../test/shared/helper.js';
+import {
+  describe,
+  it,
+} from '../../../test/shared/mocha-extensions.js';
+import {
+  clickSelectButtonItem,
+  createAndStartRecording,
+  enableAndOpenRecorderPanel,
+  getCurrentRecording,
+  stopRecording,
+  toggleCodeView,
+  assertRecordingMatchesSnapshot,
+} from './helpers.js';
+
+describe('Recorder', function() {
+  if (this.timeout() !== 0) {
+    this.timeout(5000);
+  }
+
+  async function assertStepList(expectedStepList: string[]) {
+    const {frontend} = getBrowserAndPages();
+    const actualStepList = await frontend.$$eval(
+        'pierce/.step:not(.is-start-of-group) .action .main-title',
+        actions => actions.map(e => (e as HTMLElement).innerText),
+    );
+    assert.deepEqual(actualStepList, expectedStepList);
+  }
+
+  async function record() {
+    const {target, frontend} = getBrowserAndPages();
+    await target.bringToFront();
+    await frontend.bringToFront();
+    await frontend.waitForSelector('pierce/.settings');
+    await target.bringToFront();
+    const element = await target.waitForSelector('a[href="recorder2.html"]');
+    await element?.click();
+    await frontend.bringToFront();
+  }
+
+  describe('UI', () => {
+    beforeEach(async () => {
+      await enableAndOpenRecorderPanel('recorder/recorder.html');
+      await createAndStartRecording('Test');
+    });
+
+    describe('Record', () => {
+      it('should record a simple flow', async () => {
+        const {target, frontend} = getBrowserAndPages();
+        await target.bringToFront();
+        await frontend.bringToFront();
+        await frontend.waitForSelector('pierce/.settings');
+        await target.click('#test');
+        await frontend.bringToFront();
+
+        await stopRecording();
+        await assertStepList([
+          'Set viewport',
+          'Navigate' as StepType.Navigate,
+          'Click' as StepType.Click,
+        ]);
+      });
+
+      it('should replay a simple flow', async () => {
+        const {target, frontend} = getBrowserAndPages();
+        await target.bringToFront();
+        await frontend.bringToFront();
+        await frontend.waitForSelector('pierce/.settings');
+        const element = await target.waitForSelector(
+            'a[href="recorder2.html"]',
+        );
+        await element?.click();
+        await frontend.bringToFront();
+
+        await stopRecording();
+
+        const steps = await frontend.$$eval(
+            'pierce/.step:not(.is-start-of-group) .action .main-title',
+            actions => actions.map(e => (e as HTMLElement).innerText),
+        );
+        assert.deepEqual(steps, [
+          'Set viewport',
+          'Navigate' as StepType.Navigate,
+          'Click' as StepType.Click,
+        ]);
+
+        await target.goto('about:blank');
+
+        // Wait for all steps to complete successfully
+        const promise = waitForFunction(async () => {
+          const successfulSteps = await frontend.$$eval(
+              'pierce/.step:not(.is-start-of-group).is-success .action .main-title',
+              actions => actions.map(e => (e as HTMLElement).innerText),
+          );
+          return steps.length === successfulSteps.length;
+        });
+        await clickSelectButtonItem(
+            'Normal (Default)',
+            'devtools-replay-button',
+        );
+        await target.bringToFront();
+        await promise;
+        assert.strictEqual(
+            target.url(),
+            `${getResourcesPath()}/recorder/recorder2.html`,
+        );
+      });
+
+      it('should rename a recording', async () => {
+        const {target, frontend} = getBrowserAndPages();
+        await target.bringToFront();
+        await frontend.bringToFront();
+        await frontend.waitForSelector('pierce/.settings');
+        await target.click('#test');
+        await frontend.bringToFront();
+        await stopRecording();
+
+        const button = await waitForAria('Edit title');
+        await button.click();
+        const input = (await frontend.waitForSelector(
+                          'pierce/#title-input',
+                          )) as ElementHandle<HTMLInputElement>;
+        await input.type(' with Hello world', {delay: 50});
+
+        const recording = await getCurrentRecording();
+        assertRecordingMatchesSnapshot(recording);
+      });
+
+      describe('Selector picker', () => {
+        async function pickSelectorsForQuery(
+            query: string,
+            frontend: Page,
+            target: Page,
+        ) {
+          await renderCoordinatorQueueEmpty();
+
+          // Activate selector picker.
+          const picker = await frontend.waitForSelector(
+              'pierce/.selector-picker',
+          );
+          assertNotNullOrUndefined(picker);
+          await picker.click();
+
+          // Click element and wait for selector picking to stop.
+          const element = await target.waitForSelector(query);
+          assertNotNullOrUndefined(element);
+          await element.click();
+        }
+
+        async function expandStep(frontend: Page, index: number) {
+          await frontend.bringToFront();
+          // TODO(crbug.com/1411283): figure out why misclicks happen here.
+          await waitForAnimationFrame();
+          await click(`.step[data-step-index="${index}"] .action`);
+          await waitFor('.expanded');
+        }
+
+        it('should select through the selector picker', async () => {
+          const {target, frontend} = getBrowserAndPages();
+          await frontend.bringToFront();
+          await frontend.waitForSelector('pierce/.settings');
+
+          await target.bringToFront();
+          const element = await target.waitForSelector(
+              'a[href="recorder2.html"]',
+          );
+          await element?.click();
+
+          await stopRecording();
+
+          await expandStep(frontend, 2);
+          await pickSelectorsForQuery('#test-button', frontend, target);
+
+          const recording = await getCurrentRecording();
+          assertRecordingMatchesSnapshot(recording);
+        });
+
+        it('should select through the selector picker twice', async () => {
+          const {target, frontend} = getBrowserAndPages();
+          await frontend.bringToFront();
+          await frontend.waitForSelector('pierce/.settings');
+
+          await target.bringToFront();
+          const element = await target.waitForSelector(
+              'a[href="recorder2.html"]',
+          );
+          await element?.click();
+
+          await stopRecording();
+
+          await expandStep(frontend, 2);
+          await pickSelectorsForQuery('#test-button', frontend, target);
+
+          let recording = await getCurrentRecording();
+          assertRecordingMatchesSnapshot(recording);
+
+          await pickSelectorsForQuery(
+              'a[href="recorder.html"]',
+              frontend,
+              target,
+          );
+
+          recording = await getCurrentRecording();
+          assertRecordingMatchesSnapshot(recording);
+        });
+
+        it('should select through the selector picker during recording', async () => {
+          const {target, frontend} = getBrowserAndPages();
+          await frontend.bringToFront();
+          await frontend.waitForSelector('pierce/.settings');
+
+          await target.bringToFront();
+          const element = await target.waitForSelector(
+              'a[href="recorder2.html"]',
+          );
+          await element?.click();
+
+          await expandStep(frontend, 2);
+          await pickSelectorsForQuery('#test-button', frontend, target);
+
+          await stopRecording();
+
+          const recording = await getCurrentRecording();
+          assertRecordingMatchesSnapshot(recording);
+        });
+      });
+    });
+
+    describe('Settings', () => {
+      it('should change network settings', async () => {
+        const {target, frontend} = getBrowserAndPages();
+        await target.bringToFront();
+        await frontend.bringToFront();
+        await frontend.waitForSelector('pierce/.settings');
+        await target.click('#test');
+        await frontend.bringToFront();
+        await stopRecording();
+
+        await click('aria/Edit replay settings');
+        await waitForAnimationFrame();
+
+        const selectMenu = await click(
+            '.editable-setting devtools-select-menu',
+        );
+        await waitForAnimationFrame();
+
+        await click('devtools-menu-item:nth-child(3)', {root: selectMenu});
+        await waitForAnimationFrame();
+
+        const recording = await getCurrentRecording();
+        assertRecordingMatchesSnapshot(recording);
+      });
+
+      it('should change the user flow timeout', async () => {
+        await record();
+        await stopRecording();
+        await assertStepList([
+          'Set viewport',
+          'Navigate' as StepType.Navigate,
+          'Click' as StepType.Click,
+        ]);
+
+        const button = await waitForAria('Edit replay settings');
+        await button.click();
+        const input = await waitForAria('Timeout');
+        // Clear the default value.
+        await input.evaluate((el: Element): void => {
+          (el as HTMLInputElement).value = '';
+        });
+        await input.type('2000');
+        await button.click();
+
+        const recording = await getCurrentRecording();
+        if (typeof recording !== 'object' || recording === null) {
+          throw new Error('Recording is corrupted');
+        }
+        assert.strictEqual((recording as {timeout: number}).timeout, 2000);
+      });
+    });
+
+    describe('Shortcuts', () => {
+      it('should toggle code view with shortcut', async () => {
+        let noSplitView = await waitForNone('devtools-split-view');
+        assert.isTrue(noSplitView);
+
+        await toggleCodeView();
+        const splitView = await waitFor('devtools-split-view');
+        assert.isOk(splitView);
+
+        await toggleCodeView();
+        noSplitView = await waitForNone('devtools-split-view');
+        assert.isTrue(noSplitView);
+      });
+    });
+  });
+
+  describe('Header', () => {
+    beforeEach(async () => {
+      await enableAndOpenRecorderPanel('recorder/recorder.html');
+    });
+
+    describe('Shortcut Dialog', () => {
+      it('should open the shortcut dialog', async () => {
+        const {frontend} = getBrowserAndPages();
+        await frontend.bringToFront();
+        const shortcutDialog = await waitFor('devtools-shortcut-dialog');
+
+        await click('devtools-button', {root: shortcutDialog});
+
+        const dialog = await waitFor('devtools-dialog', shortcutDialog);
+        assert.isOk(dialog);
+
+        const shortcuts = await $$('.keybinds-list-item', dialog);
+        assert.lengthOf(shortcuts, 4);
+      });
+    });
+  });
+
+  describe('Recording list', () => {
+    beforeEach(async () => {
+      await enableAndOpenRecorderPanel('recorder/recorder.html');
+      await createAndStartRecording('Test');
+    });
+
+    it('can delete a recording from the list', async () => {
+      const {frontend} = getBrowserAndPages();
+      await frontend.bringToFront();
+      await stopRecording();
+
+      await frontend.select('pierce/select', 'AllRecordingsPage');
+      await click('pierce/.delete-recording-button');
+
+      await frontend.waitForSelector('pierce/devtools-start-view');
+    });
+  });
+});
diff --git a/test/e2e/resources/BUILD.gn b/test/e2e/resources/BUILD.gn
index ce194b7..34dc763 100644
--- a/test/e2e/resources/BUILD.gn
+++ b/test/e2e/resources/BUILD.gn
@@ -32,6 +32,7 @@
     "sensors",
     "sources",
     "webaudio",
+    "recorder",
   ]
 }
 
diff --git a/test/e2e/resources/recorder/BUILD.gn b/test/e2e/resources/recorder/BUILD.gn
new file mode 100644
index 0000000..b0c3df9
--- /dev/null
+++ b/test/e2e/resources/recorder/BUILD.gn
@@ -0,0 +1,28 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../scripts/build/ninja/copy.gni")
+
+copy_to_gen("recorder") {
+  sources = [
+    "checkbox.html",
+    "form.html",
+    "iframe1.html",
+    "iframe2.html",
+    "input.html",
+    "multiple-navigations.html",
+    "oopif.html",
+    "popup.html",
+    "programmatic-navigation-on-keydown.html",
+    "recorder.html",
+    "recorder2.html",
+    "scroll-into-view.html",
+    "scroll.html",
+    "select.html",
+    "shadow-dynamic.html",
+    "shadow-input.html",
+    "shadow-open.html",
+    "shadow-text-node.html",
+  ]
+}
diff --git a/test/e2e/resources/recorder/checkbox.html b/test/e2e/resources/recorder/checkbox.html
new file mode 100644
index 0000000..15e05a1
--- /dev/null
+++ b/test/e2e/resources/recorder/checkbox.html
@@ -0,0 +1,9 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<label>
+  checkbox
+  <input id="checkbox" type="checkbox" />
+</label>
diff --git a/test/e2e/resources/recorder/form.html b/test/e2e/resources/recorder/form.html
new file mode 100644
index 0000000..d16d8c1
--- /dev/null
+++ b/test/e2e/resources/recorder/form.html
@@ -0,0 +1,9 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<form action="">
+  <label for="name">Name:</label>
+  <input id="name" name="name" type="text" />
+</form>
diff --git a/test/e2e/resources/recorder/iframe1.html b/test/e2e/resources/recorder/iframe1.html
new file mode 100644
index 0000000..cc21004
--- /dev/null
+++ b/test/e2e/resources/recorder/iframe1.html
@@ -0,0 +1,7 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<h1>iframe 1</h1>
+<a href="iframe2.html">To iframe 2</a>
diff --git a/test/e2e/resources/recorder/iframe2.html b/test/e2e/resources/recorder/iframe2.html
new file mode 100644
index 0000000..20f7e9a
--- /dev/null
+++ b/test/e2e/resources/recorder/iframe2.html
@@ -0,0 +1,7 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<h1>iframe 2</h1>
+<a href="iframe1.html">To iframe 1</a>
diff --git a/test/e2e/resources/recorder/input.html b/test/e2e/resources/recorder/input.html
new file mode 100644
index 0000000..1b94753
--- /dev/null
+++ b/test/e2e/resources/recorder/input.html
@@ -0,0 +1,51 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<input id="one" />
+<input id="two" />
+<input id="checkbox" type="checkbox" />
+<input id="color" type="color" />
+<input id="date" type="date" />
+<input id="reset" type="reset" />
+<input id="submit" type="submit" />
+<input id="button" type="button" />
+<input id="datetime-local" type="datetime-local" />
+<input id="email" type="email" />
+<input id="file" type="file" />
+<input id="image" type="image" />
+<input id="month" type="month" />
+<input id="number" type="number" />
+<input id="password" type="password" />
+<input id="radio" type="radio" />
+<input id="range" type="range" />
+<input id="search" type="search" />
+<input id="time" type="time" />
+<input id="url" type="url" />
+<input id="week" type="week" />
+<input id="prefilled" value="abc" />
+<input id="partially-prefilled" value="abc" />
+<input id="to-be-modified" />
+<input id="contiguous-field-1" type="text" />
+<input id="contiguous-field-2" type="text" />
+<pre id="log"></pre>
+<script>
+  window.addEventListener('input', (e) => {
+    log.innerText += e.target.id + ':' + e.target.value + '\n';
+  });
+  window.addEventListener('mousedown', (e) => {
+    if (e.target.id === 'to-be-modified') {
+      e.target.id = 'modified';
+    }
+  });
+  document.getElementById('contiguous-field-1').addEventListener(
+    'keydown',
+    (e) => {
+      if (e.target.value === 'something') {
+        document.getElementById('contiguous-field-2').focus();
+      }
+    },
+    true
+  );
+</script>
diff --git a/test/e2e/resources/recorder/multiple-navigations.html b/test/e2e/resources/recorder/multiple-navigations.html
new file mode 100644
index 0000000..d1f884f
--- /dev/null
+++ b/test/e2e/resources/recorder/multiple-navigations.html
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2021 Google LLC. All rights reserved.
+-->
+<!DOCTYPE html>
+<h1>Navigation in top page and iframe at the same time</h1>
+<button>Navigate</button>
+<script>
+  const url = new URL('./iframe1.html', document.location);
+  const iframe = document.createElement('iframe');
+  iframe.src = url.toString().replace('localhost', 'devtools.oopif.test');
+  document.body.append(iframe);
+  document.querySelector('button'). => {
+    iframe.src = new URL('./iframe2.html', document.location)
+      .toString()
+      .replace('localhost', 'devtools.oopif.test');
+    setTimeout(() => {
+      window.location.href = 'input.html';
+    }, 50);
+  };
+</script>
diff --git a/test/e2e/resources/recorder/oopif.html b/test/e2e/resources/recorder/oopif.html
new file mode 100644
index 0000000..1772d98
--- /dev/null
+++ b/test/e2e/resources/recorder/oopif.html
@@ -0,0 +1,11 @@
+<!--
+  Copyright 2021 Google LLC. All rights reserved.
+-->
+<!DOCTYPE html>
+<h1>Navigation in OOPIF iframes</h1>
+<script>
+  const url = new URL('./iframe1.html', document.location);
+  const iframe = document.createElement('iframe');
+  iframe.src = url.toString().replace('localhost', 'devtools.oopif.test');
+  document.body.append(iframe);
+</script>
diff --git a/test/e2e/resources/recorder/popup.html b/test/e2e/resources/recorder/popup.html
new file mode 100644
index 0000000..025859a
--- /dev/null
+++ b/test/e2e/resources/recorder/popup.html
@@ -0,0 +1,7 @@
+<!--
+  Copyright 2020 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<h1>Popup</h1>
+<button>Button in Popup</button>
diff --git a/test/e2e/resources/recorder/programmatic-navigation-on-keydown.html b/test/e2e/resources/recorder/programmatic-navigation-on-keydown.html
new file mode 100644
index 0000000..bd9f54a
--- /dev/null
+++ b/test/e2e/resources/recorder/programmatic-navigation-on-keydown.html
@@ -0,0 +1,15 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<input autofocus />
+<script>
+  window.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      e.stopPropagation();
+      window.location.replace('input.html');
+    }
+  });
+</script>
diff --git a/test/e2e/resources/recorder/recorder.html b/test/e2e/resources/recorder/recorder.html
new file mode 100644
index 0000000..1fb6ecf
--- /dev/null
+++ b/test/e2e/resources/recorder/recorder.html
@@ -0,0 +1,82 @@
+<!--
+  Copyright 2020 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<div>
+  <button id="mouse-button">Mouse click button</button>
+  <button id="test">Test Button</button>
+  <form id="form1">
+    <button id="form-button" type="submit">Form Button</button>
+  </form>
+  <div id="form1-result"></div>
+  <form class="form2">
+    <button><span id="span">Hello World</span></button>
+  </form>
+  <form>
+    <div><span id="span2">Hello</span> World</div>
+  </form>
+  <label for="input">Input</label>
+  <form><input id="input" /></form>
+  <a id="shadow-root" role="link">Hello&nbsp;</a>
+  <iframe id="iframe"></iframe>
+  <button id="popup">Open Popup</button>
+  <a href="recorder2.html">Page 2</a>
+  <button id="synthetic">Trigger Synthetic Event</button>
+  <button id="synthetic-handler">Synthetic Event Handler</button>
+  <button id="selector-attribute" data-devtools-test="selector-attribute">
+    Custom selector attribute
+  </button>
+</div>
+<script>
+  document
+    .getElementById('mouse-button')
+    .addEventListener('mouseup', (event) => event.preventDefault());
+  window.addEventListener('submit', (event) => {
+    if (!event.target.classList.contains('submittable')) {
+      event.preventDefault();
+    }
+  });
+  const link = document.getElementById('shadow-root');
+  const span1 = document.createElement('span');
+  link.append(span1);
+  const shadow = span1.attachShadow({ mode: 'open' });
+  const span2 = document.createElement('span');
+  span2.id = 'inner-span';
+  span2.textContent = 'World';
+  shadow.append(span2);
+
+  const iframe = document.getElementById('iframe');
+  const button = document.createElement('button');
+  button.textContent = 'iframe button';
+  button.id = 'in-iframe';
+  iframe.contentDocument.body.append(button);
+
+  const innerIframe = document.createElement('iframe');
+  const innerIframeButton = document.createElement('button');
+  innerIframeButton.textContent = 'Inner iframe button';
+  innerIframeButton.id = 'inner-iframe';
+  iframe.contentDocument.body.append(innerIframe);
+  innerIframe.contentDocument.body.append(innerIframeButton);
+
+  document.getElementById('popup').addEventListener('click', () => {
+    window.open(
+      'popup.html',
+      'Window Name ' + new Date(),
+      'resizable,scrollbars,status,width=200,height=200'
+    );
+  });
+
+  document.getElementById('synthetic').addEventListener('click', () => {
+    const handler = document.getElementById('synthetic-handler');
+    handler.addEventListener('click', () => {
+      console.log('synthetic event handled');
+    });
+    const event = new Event('click');
+    handler.dispatchEvent(event);
+  });
+
+  document.getElementById('form1').addEventListener('submit', () => {
+    document.getElementById('form1-result').textContent = 'From 1 Submitted';
+  });
+</script>
diff --git a/test/e2e/resources/recorder/recorder2.html b/test/e2e/resources/recorder/recorder2.html
new file mode 100644
index 0000000..0bf79d0
--- /dev/null
+++ b/test/e2e/resources/recorder/recorder2.html
@@ -0,0 +1,8 @@
+<!--
+  Copyright 2020 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<h1>Page 2</h1>
+<a href="recorder.html">Back to Page 1</a>
+<button id="test-button">Test button</button>
diff --git a/test/e2e/resources/recorder/scroll-into-view.html b/test/e2e/resources/recorder/scroll-into-view.html
new file mode 100644
index 0000000..f3a6fdd
--- /dev/null
+++ b/test/e2e/resources/recorder/scroll-into-view.html
@@ -0,0 +1,24 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<style>
+  html,
+  body {
+    scroll-behavior: smooth;
+    height: 200vh;
+    display: flex;
+    flex-direction: row;
+  }
+</style>
+<script>
+  // Wait for onload to make sure navigation is handled in the test script before the button is added.
+  window. => {
+    document.body.innerHTML =
+      '<button  = `clicked`" style="display: none; align-self: flex-end;">click</button>';
+    queueMicrotask(() => {
+      document.querySelector('button').style.display = 'block';
+    });
+  };
+</script>
diff --git a/test/e2e/resources/recorder/scroll.html b/test/e2e/resources/recorder/scroll.html
new file mode 100644
index 0000000..dac8d00
--- /dev/null
+++ b/test/e2e/resources/recorder/scroll.html
@@ -0,0 +1,9 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<div id="overflow" style="height: 100px; overflow: scroll">
+  <div style="height: 200px; background-color: green"></div>
+</div>
+<div style="height: 150vh; width: 150vw"></div>
diff --git a/test/e2e/resources/recorder/select.html b/test/e2e/resources/recorder/select.html
new file mode 100644
index 0000000..f75b736
--- /dev/null
+++ b/test/e2e/resources/recorder/select.html
@@ -0,0 +1,12 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<label>
+  Select
+  <select id="select">
+    <option value="O1">O1</option>
+    <option value="O2">O2</option>
+  </select>
+</label>
diff --git a/test/e2e/resources/recorder/shadow-dynamic.html b/test/e2e/resources/recorder/shadow-dynamic.html
new file mode 100644
index 0000000..2c10d24
--- /dev/null
+++ b/test/e2e/resources/recorder/shadow-dynamic.html
@@ -0,0 +1,22 @@
+<!--
+  Copyright 2023 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<script>
+  class CustomElement extends HTMLElement {
+    constructor() {
+      super();
+      const shadow = this.attachShadow({ mode: 'open' });
+      shadow.innerHTML = `<button id="test">Click me</button>`;
+    }
+  }
+  customElements.define('custom-element', CustomElement);
+
+  function addMore() {
+    setTimeout(() => {
+      document.body.append(new CustomElement());
+    }, 100);
+  }
+</script>
+<custom-element >
diff --git a/test/e2e/resources/recorder/shadow-input.html b/test/e2e/resources/recorder/shadow-input.html
new file mode 100644
index 0000000..e40490b
--- /dev/null
+++ b/test/e2e/resources/recorder/shadow-input.html
@@ -0,0 +1,16 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+
+<script>
+  class CustomInputElement extends HTMLElement {
+    constructor() {
+      super();
+      this.attachShadow({ mode: 'open' }).innerHTML = '<input>';
+    }
+  }
+  customElements.define('custom-input', CustomInputElement);
+</script>
+<custom-input></custom-input>
diff --git a/test/e2e/resources/recorder/shadow-open.html b/test/e2e/resources/recorder/shadow-open.html
new file mode 100644
index 0000000..1fd9c5ba
--- /dev/null
+++ b/test/e2e/resources/recorder/shadow-open.html
@@ -0,0 +1,24 @@
+<!--
+  Copyright 2021 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<script>
+  class LoginElement extends HTMLElement {
+    constructor() {
+      super();
+      const shadow = this.attachShadow({ mode: 'open' });
+      shadow.innerHTML = `
+        <p>sss</p>
+        <button>Login</button>
+      `;
+    }
+  }
+  customElements.define('login-element', LoginElement);
+</script>
+<header>
+  <login-element></login-element>
+</header>
+<main>
+  <login-element></login-element>
+</main>
diff --git a/test/e2e/resources/recorder/shadow-text-node.html b/test/e2e/resources/recorder/shadow-text-node.html
new file mode 100644
index 0000000..1f00aec
--- /dev/null
+++ b/test/e2e/resources/recorder/shadow-text-node.html
@@ -0,0 +1,16 @@
+<!--
+  Copyright 2022 The Chromium Authors. All rights reserved.
+  Use of this source code is governed by a BSD-style license that can be
+  found in the LICENSE file.
+-->
+<script>
+  class CustomButton extends HTMLElement {
+    constructor() {
+      super();
+      const shadow = this.attachShadow({ mode: 'open' });
+      shadow.innerHTML = `<slot></slot>`;
+    }
+  }
+  customElements.define('custom-button', CustomButton);
+</script>
+<custom-button>Click me</custom-button>
diff --git a/test/e2e/snapshots/recorder/recorder_test.json b/test/e2e/snapshots/recorder/recorder_test.json
new file mode 100644
index 0000000..d69e098
--- /dev/null
+++ b/test/e2e/snapshots/recorder/recorder_test.json
@@ -0,0 +1,1433 @@
+{
+  "Recorder should capture the initial page as the url of the first section - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      }
+    ]
+  },
+  "Recorder should capture clicks on buttons - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Test Button"],
+          ["#test"],
+          ["xpath///*[@id=\"test\"]"],
+          ["pierce/#test"],
+          ["text/Test Button"]
+        ]
+      }
+    ]
+  },
+  "Recorder should capture non-primary clicks and double clicks - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Mouse click button"],
+          ["#mouse-button"],
+          ["xpath///*[@id=\"mouse-button\"]"],
+          ["pierce/#mouse-button"],
+          ["text/Mouse click button"]
+        ],
+        "button": "auxiliary"
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Mouse click button"],
+          ["#mouse-button"],
+          ["xpath///*[@id=\"mouse-button\"]"],
+          ["pierce/#mouse-button"],
+          ["text/Mouse click button"]
+        ],
+        "button": "secondary"
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Mouse click button"],
+          ["#mouse-button"],
+          ["xpath///*[@id=\"mouse-button\"]"],
+          ["pierce/#mouse-button"],
+          ["text/Mouse click button"]
+        ],
+        "button": "forward"
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Mouse click button"],
+          ["#mouse-button"],
+          ["xpath///*[@id=\"mouse-button\"]"],
+          ["pierce/#mouse-button"],
+          ["text/Mouse click button"]
+        ],
+        "button": "back"
+      },
+      {
+        "type": "doubleClick",
+        "target": "main",
+        "selectors": [
+          ["aria/Mouse click button"],
+          ["#mouse-button"],
+          ["xpath///*[@id=\"mouse-button\"]"],
+          ["pierce/#mouse-button"],
+          ["text/Mouse click button"]
+        ]
+      }
+    ]
+  },
+  "Recorder should capture clicks on input buttons - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Reset"],
+          ["#reset"],
+          ["xpath///*[@id=\"reset\"]"],
+          ["pierce/#reset"]
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["#submit"],
+          ["xpath///*[@id=\"submit\"]"],
+          ["pierce/#submit"]
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["#button"],
+          ["xpath///*[@id=\"button\"]"],
+          ["pierce/#button"]
+        ]
+      }
+    ]
+  },
+  "Recorder should capture clicks on buttons with custom selector attribute - 1": {
+    "title": "New Recording",
+    "selectorAttribute": "data-devtools-test",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["[data-devtools-test='selector-attribute']"],
+          ["xpath///*[@data-devtools-test=\"selector-attribute\"]"],
+          ["pierce/[data-devtools-test='selector-attribute']"],
+          ["aria/Custom selector attribute"],
+          ["text/Custom selector"]
+        ]
+      }
+    ]
+  },
+  "Recorder should capture Enter key presses on buttons - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Enter"
+      },
+      {
+        "type": "keyUp",
+        "key": "Enter",
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should not capture synthetic events - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Trigger Synthetic Event"],
+          ["#synthetic"],
+          ["xpath///*[@id=\"synthetic\"]"],
+          ["pierce/#synthetic"],
+          ["text/Trigger Synthetic"]
+        ]
+      }
+    ]
+  },
+  "Recorder should capture implicit form submissions - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/form.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/form.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Name:"],
+          ["#name"],
+          ["xpath///*[@id=\"name\"]"],
+          ["pierce/#name"]
+        ]
+      },
+      {
+        "type": "change",
+        "value": "test",
+        "selectors": [
+          ["aria/Name:"],
+          ["#name"],
+          ["xpath///*[@id=\"name\"]"],
+          ["pierce/#name"]
+        ],
+        "target": "main"
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Enter",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/form.html?name=test",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "keyUp",
+        "key": "Enter",
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should capture clicks on submit buttons inside of forms as click steps - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Form Button"],
+          ["#form-button"],
+          ["xpath///*[@id=\"form-button\"]"],
+          ["pierce/#form-button"],
+          ["text/Form Button"]
+        ]
+      }
+    ]
+  },
+  "Recorder should build an ARIA selector for the parent element that is interactive - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Hello World", "aria/[role=\"generic\"]"],
+          ["#span"],
+          ["xpath///*[@id=\"span\"]"],
+          ["pierce/#span"]
+        ]
+      }
+    ]
+  },
+  "Recorder should fall back to a css selector if an element does not have an accessible and interactive parent - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["#span2"],
+          ["xpath///*[@id=\"span2\"]"],
+          ["pierce/#span2"]
+        ]
+      }
+    ]
+  },
+  "Recorder should create an aria selector even if the element is within a shadow root - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["#shadow-root > span", "#inner-span"],
+          [
+            "xpath///*[@id=\"shadow-root\"]/span",
+            "xpath///*[@id=\"inner-span\"]"
+          ],
+          ["pierce/#inner-span"]
+        ]
+      }
+    ]
+  },
+  "Recorder should record clicks on shadow DOM elements with slots containing text nodes only - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/shadow-text-node.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/shadow-text-node.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["custom-button"],
+          ["xpath//html/body/custom-button"],
+          ["pierce/custom-button"]
+        ]
+      }
+    ]
+  },
+  "Recorder should record interactions with elements within iframes - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/iframe button"],
+          ["#in-iframe"],
+          ["xpath///*[@id=\"in-iframe\"]"],
+          ["pierce/#in-iframe"],
+          ["text/iframe button"]
+        ],
+        "frame": [0]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Inner iframe button"],
+          ["#inner-iframe"],
+          ["xpath///*[@id=\"inner-iframe\"]"],
+          ["pierce/#inner-iframe"],
+          ["text/Inner iframe"]
+        ],
+        "frame": [0, 0]
+      }
+    ]
+  },
+  "Recorder should wait for navigations in the generated scripts - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Page 2"],
+          ["a:nth-of-type(2)"],
+          ["xpath//html/body/div/a[2]"],
+          ["pierce/a:nth-of-type(2)"],
+          ["text/Page 2"]
+        ],
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder2.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Back to Page 1"],
+          ["a"],
+          ["xpath//html/body/a"],
+          ["pierce/a"],
+          ["text/Back to Page"]
+        ],
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      }
+    ]
+  },
+  "Recorder should capture keyboard events on inputs - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Tab"
+      },
+      {
+        "type": "keyUp",
+        "key": "Tab",
+        "target": "main"
+      },
+      {
+        "type": "change",
+        "value": "1",
+        "selectors": [["#one"], ["xpath///*[@id=\"one\"]"], ["pierce/#one"]],
+        "target": "main"
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Tab"
+      },
+      {
+        "type": "keyUp",
+        "key": "Tab",
+        "target": "main"
+      },
+      {
+        "type": "change",
+        "value": "2",
+        "selectors": [["#two"], ["xpath///*[@id=\"two\"]"], ["pierce/#two"]],
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should capture keyboard events on non-text inputs - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["#color"],
+          ["xpath///*[@id=\"color\"]"],
+          ["pierce/#color"],
+          ["text/#000000"]
+        ]
+      },
+      {
+        "type": "change",
+        "value": "#333333",
+        "selectors": [
+          ["#color"],
+          ["xpath///*[@id=\"color\"]"],
+          ["pierce/#color"],
+          ["text/#000000"]
+        ],
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should capture navigation without change - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Tab"
+      },
+      {
+        "type": "keyUp",
+        "key": "Tab",
+        "target": "main"
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Shift"
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Tab"
+      },
+      {
+        "type": "keyUp",
+        "key": "Tab",
+        "target": "main"
+      },
+      {
+        "type": "keyUp",
+        "key": "Shift",
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should capture a change that causes navigation without blur or change - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/programmatic-navigation-on-keydown.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/programmatic-navigation-on-keydown.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "change",
+        "value": "1",
+        "selectors": [["input"], ["xpath//html/body/input"], ["pierce/input"]],
+        "target": "main"
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Enter",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "keyUp",
+        "key": "Enter",
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should associate events with right navigations - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/multiple-navigations.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/multiple-navigations.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Navigate"],
+          ["button"],
+          ["xpath//html/body/button"],
+          ["pierce/button"],
+          ["text/Navigate"]
+        ],
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      }
+    ]
+  },
+  "Recorder should work for select elements - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/select.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/select.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Select"],
+          ["#select"],
+          ["xpath///*[@id=\"select\"]"],
+          ["pierce/#select"]
+        ]
+      },
+      {
+        "type": "change",
+        "value": "O2",
+        "selectors": [
+          ["aria/Select"],
+          ["#select"],
+          ["xpath///*[@id=\"select\"]"],
+          ["pierce/#select"]
+        ],
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should work for checkbox elements - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/checkbox.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/checkbox.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/checkbox"],
+          ["#checkbox"],
+          ["xpath///*[@id=\"checkbox\"]"],
+          ["pierce/#checkbox"]
+        ]
+      }
+    ]
+  },
+  "Recorder should work for elements modified on mousedown - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["#to-be-modified"],
+          ["xpath///*[@id=\"to-be-modified\"]"],
+          ["pierce/#to-be-modified"]
+        ]
+      }
+    ]
+  },
+  "Recorder should record OOPIF interactions - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/oopif.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/oopif.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "https://devtools.oopif.test:<test-port>/test/e2e/resources/recorder/iframe1.html",
+        "selectors": [
+          ["aria/To iframe 2"],
+          ["a"],
+          ["xpath//html/body/a"],
+          ["pierce/a"],
+          ["text/To iframe 2"]
+        ],
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://devtools.oopif.test:<test-port>/test/e2e/resources/recorder/iframe2.html",
+            "title": ""
+          }
+        ]
+      }
+    ]
+  },
+  "Recorder should record interactions with popups - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Open Popup"],
+          ["#popup"],
+          ["xpath///*[@id=\"popup\"]"],
+          ["pierce/#popup"],
+          ["text/Open Popup"]
+        ]
+      },
+      {
+        "type": "click",
+        "target": "https://localhost:<test-port>/test/e2e/resources/recorder/popup.html",
+        "selectors": [
+          ["aria/Button in Popup"],
+          ["button"],
+          ["xpath//html/body/button"],
+          ["pierce/button"],
+          ["text/Button in Popup"]
+        ]
+      }
+    ]
+  },
+  "Recorder should break out shifts in text controls - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Tab"
+      },
+      {
+        "type": "keyUp",
+        "key": "Tab",
+        "target": "main"
+      },
+      {
+        "type": "change",
+        "value": "1",
+        "selectors": [["#one"], ["xpath///*[@id=\"one\"]"], ["pierce/#one"]],
+        "target": "main"
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "Shift"
+      },
+      {
+        "type": "keyUp",
+        "key": "Shift",
+        "target": "main"
+      },
+      {
+        "type": "change",
+        "value": "1d",
+        "selectors": [["#one"], ["xpath///*[@id=\"one\"]"], ["pierce/#one"]],
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should work with contiguous inputs - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "change",
+        "value": "something",
+        "selectors": [
+          ["#contiguous-field-1"],
+          ["xpath///*[@id=\"contiguous-field-1\"]"],
+          ["pierce/#contiguous-field-1"]
+        ],
+        "target": "main"
+      },
+      {
+        "type": "change",
+        "value": "works",
+        "selectors": [
+          ["#contiguous-field-2"],
+          ["xpath///*[@id=\"contiguous-field-2\"]"],
+          ["pierce/#contiguous-field-2"]
+        ],
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should work with shadow inputs - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/shadow-input.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/shadow-input.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["custom-input", "input"],
+          ["xpath//html/body/custom-input", "xpath//input"],
+          ["pierce/input"]
+        ]
+      },
+      {
+        "type": "change",
+        "value": "works",
+        "selectors": [
+          ["custom-input", "input"],
+          ["xpath//html/body/custom-input", "xpath//input"],
+          ["pierce/input"]
+        ],
+        "target": "main"
+      }
+    ]
+  },
+  "Recorder should edit while recording - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Test Button"],
+          ["#test"],
+          ["xpath///*[@id=\"test\"]"],
+          ["pierce/#test"],
+          ["text/Test Button"]
+        ]
+      }
+    ]
+  },
+  "Recorder should edit the type while recording - 1": {
+    "type": "emulateNetworkConditions",
+    "download": 1000,
+    "latency": 25,
+    "upload": 1000
+  },
+  "Recorder should edit the type while recording - 2": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "emulateNetworkConditions",
+        "download": 1000,
+        "latency": 25,
+        "upload": 1000
+      }
+    ]
+  },
+  "Recorder should add an assertion through the button - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "waitForElement",
+        "selectors": [[".cls"]]
+      }
+    ]
+  },
+  "Recorder Shortcuts should start with keyboard shortcut while on the create page - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      }
+    ]
+  },
+  "Recorder Shortcuts should stop with keyboard shortcut without recording it - 1": {
+    "title": "Test recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      }
+    ]
+  },
+  "Recorder Shortcuts should stop recording with shortcut on the target - 1": {
+    "title": "New Recording",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "keyDown",
+        "target": "main",
+        "key": "e"
+      },
+      {
+        "type": "keyUp",
+        "key": "e",
+        "target": "main"
+      }
+    ]
+  }
+}
diff --git a/test/e2e/snapshots/recorder/ui_test.json b/test/e2e/snapshots/recorder/ui_test.json
new file mode 100644
index 0000000..dd41445
--- /dev/null
+++ b/test/e2e/snapshots/recorder/ui_test.json
@@ -0,0 +1,252 @@
+{
+  "Recorder UI Record should rename a recording - 1": {
+    "title": "Test with Hello world",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Test Button"],
+          ["#test"],
+          ["xpath///*[@id=\"test\"]"],
+          ["pierce/#test"],
+          ["text/Test Button"]
+        ]
+      }
+    ]
+  },
+  "Recorder UI Record Selector picker should select through the selector picker - 1": {
+    "title": "Test",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder2.html",
+            "title": ""
+          }
+        ],
+        "target": "main",
+        "selectors": [
+          "aria/Test button",
+          "#test-button",
+          "xpath///*[@id=\"test-button\"]",
+          "pierce/#test-button",
+          "text/Test button"
+        ]
+      }
+    ]
+  },
+  "Recorder UI Record Selector picker should select through the selector picker twice - 1": {
+    "title": "Test",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder2.html",
+            "title": ""
+          }
+        ],
+        "target": "main",
+        "selectors": [
+          "aria/Test button",
+          "#test-button",
+          "xpath///*[@id=\"test-button\"]",
+          "pierce/#test-button",
+          "text/Test button"
+        ]
+      }
+    ]
+  },
+  "Recorder UI Record Selector picker should select through the selector picker twice - 2": {
+    "title": "Test",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder2.html",
+            "title": ""
+          }
+        ],
+        "target": "main",
+        "selectors": [
+          "aria/Back to Page 1",
+          "a",
+          "xpath//html/body/a",
+          "pierce/a",
+          "text/Back to Page"
+        ]
+      }
+    ]
+  },
+  "Recorder UI Record Selector picker should select through the selector picker during recording - 1": {
+    "title": "Test",
+    "steps": [
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder2.html",
+            "title": ""
+          }
+        ],
+        "target": "main",
+        "selectors": [
+          "aria/Test button",
+          "#test-button",
+          "xpath///*[@id=\"test-button\"]",
+          "pierce/#test-button",
+          "text/Test button"
+        ]
+      }
+    ]
+  },
+  "Recorder UI Settings should change network settings - 1": {
+    "title": "Test",
+    "steps": [
+      {
+        "type": "emulateNetworkConditions",
+        "download": 50000,
+        "upload": 50000,
+        "latency": 2000
+      },
+      {
+        "type": "setViewport",
+        "width": 1280,
+        "height": 720,
+        "deviceScaleFactor": 1,
+        "isMobile": false,
+        "hasTouch": false,
+        "isLandscape": false
+      },
+      {
+        "type": "navigate",
+        "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+        "assertedEvents": [
+          {
+            "type": "navigation",
+            "url": "https://localhost:<test-port>/test/e2e/resources/recorder/recorder.html",
+            "title": ""
+          }
+        ]
+      },
+      {
+        "type": "click",
+        "target": "main",
+        "selectors": [
+          ["aria/Test Button"],
+          ["#test"],
+          ["xpath///*[@id=\"test\"]"],
+          ["pierce/#test"],
+          ["text/Test Button"]
+        ]
+      }
+    ]
+  }
+}
diff --git a/test/interactions/BUILD.gn b/test/interactions/BUILD.gn
index c9bd609..96a782b 100644
--- a/test/interactions/BUILD.gn
+++ b/test/interactions/BUILD.gn
@@ -20,6 +20,7 @@
     "data_grid_controller",
     "helpers",
     "panels/performance/timeline",
+    "panels/recorder/injected",
     "text_editor",
     "tree_outline",
     "ui/components",
diff --git a/test/interactions/panels/recorder/injected/BUILD.gn b/test/interactions/panels/recorder/injected/BUILD.gn
new file mode 100644
index 0000000..be248e2
--- /dev/null
+++ b/test/interactions/panels/recorder/injected/BUILD.gn
@@ -0,0 +1,18 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../../third_party/typescript/typescript.gni")
+
+node_ts_library("injected") {
+  sources = [ "injected_test.ts" ]
+
+  deps = [
+    "../../../../../test/e2e/helpers",
+    "../../../../../test/interactions/helpers",
+    "../../../../../test/shared",
+    "../../../../../front_end/panels/recorder/injected:bundle",
+    "../../../../../front_end/panels/recorder/models:bundle",
+  ]
+}
diff --git a/test/interactions/panels/recorder/injected/injected_test.ts b/test/interactions/panels/recorder/injected/injected_test.ts
new file mode 100644
index 0000000..d5f09d9
--- /dev/null
+++ b/test/interactions/panels/recorder/injected/injected_test.ts
@@ -0,0 +1,263 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {assert} from 'chai';
+
+import {
+  loadComponentDocExample,
+  preloadForCodeCoverage,
+} from '../../../../../test/interactions/helpers/shared.js';
+import {getBrowserAndPages} from '../../../../../test/shared/helper.js';
+import {
+  describe,
+  it,
+} from '../../../../../test/shared/mocha-extensions.js';
+import {type Schema} from '../../../../../front_end/panels/recorder/models/models.js';
+import {assertMatchesJSONSnapshot} from '../../../../../test/shared/snapshots.js';
+
+import {type DevToolsRecorder} from '../../../../../front_end/panels/recorder/injected/injected.js';
+
+describe('Injected', () => {
+  preloadForCodeCoverage('recorder_injected/basic.html');
+
+  beforeEach(async () => {
+    await loadComponentDocExample('recorder_injected/basic.html');
+
+    const {frontend} = getBrowserAndPages();
+    await frontend.evaluate(() => {
+      (window.DevToolsRecorder as DevToolsRecorder)
+          .startRecording(
+              {
+                // We don't have the access to the actual bindings here. Therefore, the test assumes
+                // that the markup is explicitly annotated with the following attributes.
+                getAccessibleName: (element: Node) => {
+                  if (!('getAttribute' in element)) {
+                    return '';
+                  }
+                  return (element as Element).getAttribute('aria-name') || '';
+                },
+                getAccessibleRole: (element: Node) => {
+                  if (!('getAttribute' in element)) {
+                    return 'generic';
+                  }
+                  return (element as Element).getAttribute('aria-role') || '';
+                },
+              },
+              {
+                debug: false,
+                allowUntrustedEvents: true,
+                selectorTypesToRecord: [
+                  'xpath',
+                  'css',
+                  'text',
+                  'aria',
+                  'pierce',
+                ] as Schema.SelectorType[],
+              },
+          );
+    });
+  });
+
+  afterEach(async () => {
+    const {frontend} = getBrowserAndPages();
+    await frontend.evaluate(() => {
+      window.DevToolsRecorder.stopRecording();
+    });
+  });
+
+  it('should get selectors for an element', async () => {
+    const {frontend} = getBrowserAndPages();
+    const selectors = await frontend.evaluate(() => {
+      const target = document.querySelector('#buttonNoARIA');
+      if (!target) {
+        throw new Error('#buttonNoARIA not found');
+      }
+      return window.DevToolsRecorder.recordingClientForTesting.getSelectors(
+          target,
+      );
+    });
+    assertMatchesJSONSnapshot(selectors);
+  });
+
+  it('should get selectors for elements with custom selector attributes', async () => {
+    const {frontend} = getBrowserAndPages();
+    const selectors = await frontend.evaluate(() => {
+      const targets = [
+        ...document.querySelectorAll('.custom-selector-attribute'),
+        document.querySelector('#shadow-root-with-custom-selectors')?.shadowRoot?.querySelector('button') as
+            HTMLButtonElement,
+      ];
+      return targets.map(
+          window.DevToolsRecorder.recordingClientForTesting.getSelectors,
+      );
+    });
+    assertMatchesJSONSnapshot(selectors);
+  });
+
+  it('should get selectors for shadow root elements', async () => {
+    const {frontend} = getBrowserAndPages();
+    const selectors = await frontend.evaluate(() => {
+      const target = document.querySelector('main')
+                         ?.querySelector('shadow-css-selector-element')
+                         ?.shadowRoot?.querySelector('#insideShadowRoot');
+      if (!target) {
+        throw new Error('#insideShadowRoot is not found');
+      }
+      return window.DevToolsRecorder.recordingClientForTesting.getSelectors(
+          target,
+      );
+    });
+    assertMatchesJSONSnapshot(selectors);
+  });
+
+  it('should get an ARIA selector for shadow root elements', async () => {
+    const {frontend} = getBrowserAndPages();
+    const selectors = await frontend.evaluate(() => {
+      const target = document.querySelector('[aria-role="main"]')
+                         ?.querySelector('shadow-aria-selector-element')
+                         ?.shadowRoot?.querySelector('button');
+      if (!target) {
+        throw new Error('button is not found');
+      }
+      return window.DevToolsRecorder.recordingClientForTesting.getSelectors(
+          target,
+      );
+    });
+    assertMatchesJSONSnapshot(selectors);
+  });
+
+  it('should not get an ARIA selector if the target element has no name or role', async () => {
+    const {frontend} = getBrowserAndPages();
+    const selectors = await frontend.evaluate(() => {
+      const target = document.querySelector('#no-aria-name-or-role');
+      if (!target) {
+        throw new Error('button is not found');
+      }
+      return window.DevToolsRecorder.recordingClientForTesting.getSelectors(
+          target,
+      );
+    });
+    assertMatchesJSONSnapshot(selectors);
+  });
+
+  describe('CSS selectors', () => {
+    it('should query CSS selectors', async () => {
+      const {frontend} = getBrowserAndPages();
+      const results = await frontend.evaluate(() => {
+        return [
+          window.DevToolsRecorder.recordingClientForTesting
+              .queryCSSSelectorAllForTesting(
+                  ['[data-qa=custom-id]', '[data-testid=shadow\\ button]'],
+                  )
+              .length,
+          window.DevToolsRecorder.recordingClientForTesting
+              .queryCSSSelectorAllForTesting(
+                  ['[data-qa=custom-id]'],
+                  )
+              .length,
+          window.DevToolsRecorder.recordingClientForTesting
+              .queryCSSSelectorAllForTesting(
+                  '[data-qa=custom-id]',
+                  )
+              .length,
+          window.DevToolsRecorder.recordingClientForTesting
+              .queryCSSSelectorAllForTesting(
+                  '.doesnotexist',
+                  )
+              .length,
+          window.DevToolsRecorder.recordingClientForTesting
+              .queryCSSSelectorAllForTesting(
+                  ['[data-qa=custom-id]', '.doesnotexist'],
+                  )
+              .length,
+          window.DevToolsRecorder.recordingClientForTesting
+              .queryCSSSelectorAllForTesting(
+                  ['#notunique'],
+                  )
+              .length,
+        ];
+      });
+      assert.deepStrictEqual(results, [1, 1, 1, 0, 0, 2]);
+    });
+
+    it('should return not-optimized CSS selectors for duplicate elements', async () => {
+      const {frontend} = getBrowserAndPages();
+      const selector = await frontend.evaluate(() => {
+        const target = document.querySelector('#notunique');
+        if (!target) {
+          throw new Error('#notunique is not found');
+        }
+        return window.DevToolsRecorder.recordingClientForTesting.getCSSSelector(
+            target,
+        );
+      });
+      assertMatchesJSONSnapshot(selector);
+    });
+  });
+
+  describe('Text selectors', () => {
+    const getSelectorOfButtonWithLength = (length: number) => {
+      const {frontend} = getBrowserAndPages();
+      return frontend.evaluate(length => {
+        const selector = `#buttonWithLength${length}`;
+        const target = document.querySelector(selector);
+        if (!target) {
+          throw new Error(`${selector} could not be found.`);
+        }
+        if (target.innerHTML.length !== length) {
+          throw new Error(`${selector} is not of length ${length}`);
+        }
+        return window.DevToolsRecorder.recordingClientForTesting.getTextSelector(
+            target,
+        );
+      }, length);
+    };
+    const MINIMUM_LENGTH = 12;
+    const MAXIMUM_LENGTH = 64;
+    const SAME_PREFIX_TEXT_LENGTH = 32;
+
+    it('should return a text selector for elements < minimum length', async () => {
+      const selectors = await getSelectorOfButtonWithLength(MINIMUM_LENGTH - 1);
+      assertMatchesJSONSnapshot(selectors);
+    });
+    it('should return a text selector for elements == minimum length', async () => {
+      const selectors = await getSelectorOfButtonWithLength(MINIMUM_LENGTH);
+      assertMatchesJSONSnapshot(selectors);
+    });
+    it('should return a text selector for elements == maximum length', async () => {
+      const selectors = await getSelectorOfButtonWithLength(MAXIMUM_LENGTH);
+      assertMatchesJSONSnapshot(selectors);
+    });
+    it('should not return a text selector for elements > maximum length', async () => {
+      const selectors = await getSelectorOfButtonWithLength(MAXIMUM_LENGTH + 1);
+      assert.deepStrictEqual(selectors, undefined);
+    });
+    it('should return a text selector correctly with same prefix elements', async () => {
+      let selectors = await getSelectorOfButtonWithLength(
+          SAME_PREFIX_TEXT_LENGTH,
+      );
+      assertMatchesJSONSnapshot(selectors, {name: 'Smaller'});
+      selectors = await getSelectorOfButtonWithLength(
+          SAME_PREFIX_TEXT_LENGTH + 1,
+      );
+      assertMatchesJSONSnapshot(selectors, {name: 'Larger'});
+    });
+    it('should trim text selectors', async () => {
+      const {frontend} = getBrowserAndPages();
+      const selectors = await frontend.evaluate(() => {
+        const selector = '#buttonWithNewLines';
+        const target = document.querySelector(selector);
+        if (!target) {
+          throw new Error(`${selector} could not be found.`);
+        }
+        return window.DevToolsRecorder.recordingClientForTesting.getTextSelector(
+            target,
+        );
+      });
+      assertMatchesJSONSnapshot(selectors);
+    });
+  });
+});
diff --git a/test/interactions/snapshots/injected_test.json b/test/interactions/snapshots/injected_test.json
new file mode 100644
index 0000000..8c0782e
--- /dev/null
+++ b/test/interactions/snapshots/injected_test.json
@@ -0,0 +1,120 @@
+{
+  "Injected should get selectors for an element - 1": [
+    [
+      "#buttonNoARIA"
+    ],
+    [
+      "xpath///*[@id=\"buttonNoARIA\"]"
+    ],
+    [
+      "pierce/#buttonNoARIA"
+    ]
+  ],
+  "Injected should get selectors for elements with custom selector attributes - 1": [
+    [
+      [
+        "[data-testid='unique']"
+      ],
+      [
+        "xpath///*[@data-testid=\"unique\"]"
+      ],
+      [
+        "pierce/[data-testid='unique']"
+      ]
+    ],
+    [
+      [
+        "[data-testid='\\31 23456789']"
+      ],
+      [
+        "xpath///*[@data-testid=\"123456789\"]"
+      ],
+      [
+        "pierce/[data-testid='\\31 23456789']"
+      ],
+      [
+        "text/Custom selector (invalid"
+      ]
+    ],
+    [
+      [
+        "[data-qa='custom-id']",
+        "[data-testid='shadow\\ button']"
+      ],
+      [
+        "xpath///*[@data-qa=\"custom-id\"]",
+        "xpath///*[@data-testid=\"shadow button\"]"
+      ],
+      [
+        "pierce/[data-testid='shadow\\ button']"
+      ],
+      [
+        "text/Shadow button"
+      ]
+    ]
+  ],
+  "Injected should get selectors for shadow root elements - 1": [
+    [
+      "main > shadow-css-selector-element",
+      "#insideShadowRoot"
+    ],
+    [
+      "xpath//html/body/main/shadow-css-selector-element",
+      "xpath///*[@id=\"insideShadowRoot\"]"
+    ],
+    [
+      "pierce/main > shadow-css-selector-element",
+      "pierce/#insideShadowRoot"
+    ]
+  ],
+  "Injected should get an ARIA selector for shadow root elements - 1": [
+    [
+      "aria/[role=\"main\"]",
+      "aria/login"
+    ],
+    [
+      "div:nth-of-type(2) > shadow-aria-selector-element",
+      "button"
+    ],
+    [
+      "xpath//html/body/div[2]/shadow-aria-selector-element",
+      "xpath//button"
+    ],
+    [
+      "pierce/div:nth-of-type(2) > shadow-aria-selector-element",
+      "pierce/button"
+    ]
+  ],
+  "Injected should not get an ARIA selector if the target element has no name or role - 1": [
+    [
+      "#no-aria-name-or-role"
+    ],
+    [
+      "xpath///*[@id=\"no-aria-name-or-role\"]"
+    ],
+    [
+      "pierce/#no-aria-name-or-role"
+    ]
+  ],
+  "Injected CSS selectors should return not-optimized CSS selectors for duplicate elements - 1": [
+    "div:nth-of-type(3) > div:nth-of-type(2)"
+  ],
+  "Injected Text selectors should return a text selector for elements < minimum length - 1": [
+    "text/length a 11"
+  ],
+  "Injected Text selectors should return a text selector for elements == minimum length - 1": [
+    "text/length aa 12"
+  ],
+  "Injected Text selectors should return a text selector for elements == maximum length - 1": [
+    "text/length aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaaaaaaa aaaa 64"
+  ],
+  "Injected Text selectors should return a text selector correctly with same prefix elements - Smaller": [
+    "text/length aaaaaaaaa aaaaaaaaa aa 32"
+  ],
+  "Injected Text selectors should return a text selector correctly with same prefix elements - Larger": [
+    "text/length aaaaaaaaa aaaaaaaaa aaa 33"
+  ],
+  "Injected Text selectors should trim text selectors - 1": [
+    "text/with newlines"
+  ]
+}
\ No newline at end of file
diff --git a/test/unittests/front_end/BUILD.gn b/test/unittests/front_end/BUILD.gn
index 4d979d7..7d83739 100644
--- a/test/unittests/front_end/BUILD.gn
+++ b/test/unittests/front_end/BUILD.gn
@@ -64,6 +64,12 @@
     "panels/timeline",
     "panels/utils",
     "panels/webauthn",
+    "panels/recorder",
+    "panels/recorder/components",
+    "panels/recorder/converters",
+    "panels/recorder/injected/selectors",
+    "panels/recorder/models",
+    "panels/recorder/util",
     "test_setup",
     "third_party/i18n",
     "ui",
diff --git a/test/unittests/front_end/helpers/BUILD.gn b/test/unittests/front_end/helpers/BUILD.gn
index dba6933..e7f048c 100644
--- a/test/unittests/front_end/helpers/BUILD.gn
+++ b/test/unittests/front_end/helpers/BUILD.gn
@@ -30,6 +30,7 @@
     "TrackAsyncOperations.ts",
     "UISourceCodeHelpers.ts",
     "UserMetricsHelpers.ts",
+    "RecorderHelpers.ts"
   ]
 
   deps = [
@@ -44,6 +45,8 @@
     "../../../../front_end/entrypoints/shell",
     "../../../../front_end/generated:protocol",
     "../../../../front_end/models/bindings:bundle",
+    "../../../../front_end/core/sdk:bundle",
+    "../../../../front_end/panels/recorder/models:bundle",
     "../../../../front_end/models/issues_manager:bundle",
     "../../../../front_end/models/text_utils:bundle",
     "../../../../front_end/models/timeline_model:bundle",
diff --git a/test/unittests/front_end/helpers/RecorderHelpers.ts b/test/unittests/front_end/helpers/RecorderHelpers.ts
new file mode 100644
index 0000000..eacaccd
--- /dev/null
+++ b/test/unittests/front_end/helpers/RecorderHelpers.ts
@@ -0,0 +1,49 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import * as SDK from '../../../../front_end/core/sdk/sdk.js';
+import * as Recorder from '../../../../front_end/panels/recorder/models/models.js';
+import * as Models from '../../../../front_end/panels/recorder/models/models.js';
+
+export interface ClientMock {
+  send(): sinon.SinonStub;
+}
+
+export const createCustomStep = (): Models.Schema.Step => ({
+  type: Models.Schema.StepType.CustomStep,
+  name: 'dummy step',
+  parameters: {},
+});
+
+export const installMocksForRecordingPlayer = (): void => {
+  const mock = {
+    page: {
+      _client: () => ({
+        send: sinon.stub().resolves(),
+      }),
+      frames: () => [{
+        _client: () => ({send: sinon.stub().resolves()}),
+      }],
+      evaluate: () => '',
+      url() {
+        return '';
+      },
+    },
+    browser: {
+      pages: () => [mock.page],
+      disconnect: () => sinon.stub().resolves(),
+    },
+  };
+  sinon.stub(Recorder.RecordingPlayer.RecordingPlayer, 'connectPuppeteer').resolves(mock as never);
+};
+
+export const installMocksForTargetManager = (): void => {
+  const stub = {
+    suspendAllTargets: sinon.stub().resolves(),
+    resumeAllTargets: sinon.stub().resolves(),
+  };
+  sinon.stub(SDK.TargetManager.TargetManager, 'instance')
+      .callsFake(() => stub as unknown as SDK.TargetManager.TargetManager);
+};
diff --git a/test/unittests/front_end/helpers/TrackAsyncOperations.ts b/test/unittests/front_end/helpers/TrackAsyncOperations.ts
index f327102..7be45ca 100644
--- a/test/unittests/front_end/helpers/TrackAsyncOperations.ts
+++ b/test/unittests/front_end/helpers/TrackAsyncOperations.ts
@@ -16,7 +16,11 @@
   // We are tracking all asynchronous activity but let it run normally during
   // the test.
   stub('requestAnimationFrame', trackingRequestAnimationFrame);
+  // TODO(crbug.com/1442123): figure out why types are wrong.
+  // @ts-ignore
   stub('setTimeout', trackingSetTimeout);
+  // TODO(crbug.com/1442123): figure out why types are wrong.
+  // @ts-ignore
   stub('setInterval', trackingSetInterval);
   stub('requestIdleCallback', trackingRequestIdleCallback);
   stub('cancelAnimationFrame', id => cancelTrackingActivity('a' + id));
@@ -145,6 +149,8 @@
       activity.pending = false;
       resolve();
     };
+    // TODO(crbug.com/1442123): figure out why types are wrong.
+    // @ts-ignore
     id = original(setTimeout)(activity.runImmediate, time);
     activity.id = 't' + id;
     activity.cancelDelayed = () => {
diff --git a/test/unittests/front_end/panels/recorder/BUILD.gn b/test/unittests/front_end/panels/recorder/BUILD.gn
new file mode 100644
index 0000000..e7de0ff
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/BUILD.gn
@@ -0,0 +1,15 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("recorder") {
+  testonly = true
+  sources = [ "RecorderController_test.ts" ]
+
+  deps = [
+    "../../../../../test/unittests/front_end/helpers",
+    "../../../../../front_end/panels/recorder:bundle",
+  ]
+}
diff --git a/test/unittests/front_end/panels/recorder/RecorderController_test.ts b/test/unittests/front_end/panels/recorder/RecorderController_test.ts
new file mode 100644
index 0000000..b2f9fe2
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/RecorderController_test.ts
@@ -0,0 +1,492 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+const {assert} = chai;
+
+import {RecorderActions} from '../../../../../front_end/panels/recorder/recorder-actions.js';
+import {RecorderController} from '../../../../../front_end/panels/recorder/recorder.js';
+import * as Models from '../../../../../front_end/panels/recorder/models/models.js';
+import * as Components from '../../../../../front_end/panels/recorder/components/components.js';
+import {
+  describeWithEnvironment,
+} from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Coordinator from '../../../../../front_end/ui/components/render_coordinator/render_coordinator.js';
+import * as UI from '../../../../../front_end/ui/legacy/legacy.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('RecorderController', () => {
+  function makeRecording(): Models.RecordingStorage.StoredRecording {
+    const step = {
+      type: Models.Schema.StepType.Navigate as const,
+      url: 'https://example.com',
+    };
+    const recording = {
+      storageName: 'test',
+      flow: {title: 'test', steps: [step]},
+    };
+    return recording;
+  }
+
+  async function setupController(
+      recording: Models.RecordingStorage.StoredRecording,
+      ): Promise<RecorderController.RecorderController> {
+    const controller = new RecorderController.RecorderController();
+    controller.setCurrentPageForTesting(RecorderController.Pages.RecordingPage);
+    controller.setCurrentRecordingForTesting(recording);
+    controller.connectedCallback();
+    await coordinator.done();
+    return controller;
+  }
+
+  before(() => {
+    const actionRegistry = UI.ActionRegistry.ActionRegistry.instance();
+    UI.ShortcutRegistry.ShortcutRegistry.instance({
+      forceNew: true,
+      actionRegistry,
+    });
+  });
+
+  after(() => {
+    UI.ShortcutRegistry.ShortcutRegistry.removeInstance();
+    UI.ActionRegistry.ActionRegistry.removeInstance();
+  });
+
+  describe('Navigation', () => {
+    it('should return back to the previous page on recordingcancelled event', async () => {
+      const previousPage = RecorderController.Pages.AllRecordingsPage;
+      const controller = new RecorderController.RecorderController();
+      controller.setCurrentPageForTesting(previousPage);
+      controller.setCurrentPageForTesting(
+          RecorderController.Pages.CreateRecordingPage,
+      );
+      controller.connectedCallback();
+      await coordinator.done();
+
+      const createRecordingView = controller.shadowRoot?.querySelector(
+          'devtools-create-recording-view',
+      );
+      assert.ok(createRecordingView);
+      createRecordingView?.dispatchEvent(
+          new Components.CreateRecordingView.RecordingCancelledEvent(),
+      );
+
+      assert.strictEqual(controller.getCurrentPageForTesting(), previousPage);
+    });
+  });
+
+  describe('StepView', () => {
+    async function dispatchRecordingViewEvent(
+        controller: RecorderController.RecorderController,
+        event: Event,
+        ): Promise<void> {
+      const recordingView = controller.shadowRoot?.querySelector(
+          'devtools-recording-view',
+      );
+      assert.ok(recordingView);
+      recordingView?.dispatchEvent(event);
+      await coordinator.done();
+    }
+
+    beforeEach(() => {
+      Models.RecordingStorage.RecordingStorage.instance().clearForTest();
+    });
+
+    after(() => {
+      Models.RecordingStorage.RecordingStorage.instance().clearForTest();
+    });
+
+    it('should add a new step after a step', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddStep(
+              recording.flow.steps[0],
+              Components.StepView.AddStepPosition.AFTER,
+              ),
+      );
+
+      const flow = controller.getUserFlow();
+      assert.deepStrictEqual(flow, {
+        title: 'test',
+        steps: [
+          {
+            type: Models.Schema.StepType.Navigate as const,
+            url: 'https://example.com',
+          },
+          {
+            type: Models.Schema.StepType.WaitForElement as const,
+            selectors: ['body'],
+          },
+        ],
+      });
+    });
+
+    it('should add a new step after a section', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      const sections = controller.getSectionsForTesting();
+      if (!sections) {
+        throw new Error('Controller is missing sections');
+      }
+      assert.lengthOf(sections, 1);
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddStep(
+              sections[0],
+              Components.StepView.AddStepPosition.AFTER,
+              ),
+      );
+
+      const flow = controller.getUserFlow();
+      assert.deepStrictEqual(flow, {
+        title: 'test',
+        steps: [
+          {
+            type: Models.Schema.StepType.Navigate as const,
+            url: 'https://example.com',
+          },
+          {
+            type: Models.Schema.StepType.WaitForElement as const,
+            selectors: ['body'],
+          },
+        ],
+      });
+    });
+
+    it('should add a new step before a step', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddStep(
+              recording.flow.steps[0],
+              Components.StepView.AddStepPosition.BEFORE,
+              ),
+      );
+
+      const flow = controller.getUserFlow();
+      assert.deepStrictEqual(flow, {
+        title: 'test',
+        steps: [
+          {
+            type: Models.Schema.StepType.WaitForElement as const,
+            selectors: ['body'],
+          },
+          {
+            type: Models.Schema.StepType.Navigate as const,
+            url: 'https://example.com',
+          },
+        ],
+      });
+    });
+
+    it('should delete a step', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.RemoveStep(recording.flow.steps[0]),
+      );
+
+      const flow = controller.getUserFlow();
+      assert.deepStrictEqual(flow, {title: 'test', steps: []});
+    });
+
+    it('should adding a new step before a step with a breakpoint update the breakpoint indexes correctly', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+      const stepIndex = 3;
+
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddBreakpointEvent(stepIndex),
+      );
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+        stepIndex,
+      ]);
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddStep(
+              recording.flow.steps[0],
+              Components.StepView.AddStepPosition.BEFORE,
+              ),
+      );
+
+      // Breakpoint index moves to the next index
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+        stepIndex + 1,
+      ]);
+    });
+
+    it('should removing a step before a step with a breakpoint update the breakpoint indexes correctly', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+      const stepIndex = 3;
+
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddBreakpointEvent(stepIndex),
+      );
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+        stepIndex,
+      ]);
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.RemoveStep(recording.flow.steps[0]),
+      );
+
+      // Breakpoint index moves to the previous index
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+        stepIndex - 1,
+      ]);
+    });
+
+    it('should removing a step with a breakpoint remove the breakpoint index as well', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+      const stepIndex = 0;
+
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddBreakpointEvent(stepIndex),
+      );
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+        stepIndex,
+      ]);
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.RemoveStep(recording.flow.steps[stepIndex]),
+      );
+
+      // Breakpoint index is removed
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), []);
+    });
+
+    it('should "add breakpoint" event add a breakpoint', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+      const stepIndex = 1;
+
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), []);
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddBreakpointEvent(stepIndex),
+      );
+
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+        stepIndex,
+      ]);
+    });
+
+    it('should "remove breakpoint" event remove a breakpoint', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+      const stepIndex = 1;
+
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.AddBreakpointEvent(stepIndex),
+      );
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), [
+        stepIndex,
+      ]);
+      await dispatchRecordingViewEvent(
+          controller,
+          new Components.StepView.RemoveBreakpointEvent(stepIndex),
+      );
+
+      assert.deepEqual(controller.getStepBreakpointIndexesForTesting(), []);
+    });
+  });
+
+  describe('Create new recording action', () => {
+    it('should execute action', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      await controller.handleActions(RecorderActions.CreateRecording);
+
+      assert.strictEqual(
+          controller.getCurrentPageForTesting(),
+          RecorderController.Pages.CreateRecordingPage,
+      );
+    });
+
+    it('should not execute action while recording', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      controller.setIsRecordingStateForTesting(true);
+
+      await controller.handleActions(RecorderActions.CreateRecording);
+
+      assert.strictEqual(
+          controller.getCurrentPageForTesting(),
+          RecorderController.Pages.RecordingPage,
+      );
+    });
+
+    it('should not execute action while replaying', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      controller.setRecordingStateForTesting({
+        isPlaying: true,
+        isPausedOnBreakpoint: false,
+      });
+
+      await controller.handleActions(RecorderActions.CreateRecording);
+
+      assert.strictEqual(
+          controller.getCurrentPageForTesting(),
+          RecorderController.Pages.RecordingPage,
+      );
+    });
+  });
+
+  describe('Action is possible', () => {
+    it('should return true for create action when not replaying or recording', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      assert.isTrue(
+          controller.isActionPossible(RecorderActions.CreateRecording),
+      );
+    });
+
+    it('should return false for create action when recording', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      controller.setRecordingStateForTesting({
+        isPlaying: true,
+        isPausedOnBreakpoint: false,
+      });
+
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.CreateRecording),
+      );
+    });
+
+    it('should return false for create action when replaying', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      controller.setIsRecordingStateForTesting(true);
+
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.CreateRecording),
+      );
+    });
+
+    it('should return correct value for start/stop action', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      assert.isTrue(
+          controller.isActionPossible(RecorderActions.StartRecording),
+      );
+
+      controller.setRecordingStateForTesting({
+        isPlaying: true,
+        isPausedOnBreakpoint: false,
+      });
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.StartRecording),
+      );
+    });
+
+    it('should return true for replay action when on the recording page', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      controller.setCurrentPageForTesting(
+          RecorderController.Pages.RecordingPage,
+      );
+
+      assert.isTrue(
+          controller.isActionPossible(RecorderActions.ReplayRecording),
+      );
+    });
+
+    it('should return false for replay action when not on the recording page', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      controller.setCurrentPageForTesting(
+          RecorderController.Pages.AllRecordingsPage,
+      );
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.ReplayRecording),
+      );
+
+      controller.setCurrentPageForTesting(
+          RecorderController.Pages.CreateRecordingPage,
+      );
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.ReplayRecording),
+      );
+
+      controller.setCurrentPageForTesting(RecorderController.Pages.StartPage);
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.ReplayRecording),
+      );
+
+      controller.setRecordingStateForTesting({
+        isPlaying: true,
+        isPausedOnBreakpoint: false,
+      });
+      controller.setCurrentPageForTesting(
+          RecorderController.Pages.RecordingPage,
+      );
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.ReplayRecording),
+      );
+    });
+
+    it('should true for toggle when on the recording page', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      controller.setCurrentPageForTesting(
+          RecorderController.Pages.RecordingPage,
+      );
+      assert.isTrue(
+          controller.isActionPossible(RecorderActions.ToggleCodeView),
+      );
+    });
+
+    it('should false for toggle when on the recording page', async () => {
+      const recording = makeRecording();
+      const controller = await setupController(recording);
+
+      controller.setCurrentPageForTesting(
+          RecorderController.Pages.AllRecordingsPage,
+      );
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.ToggleCodeView),
+      );
+
+      controller.setCurrentPageForTesting(RecorderController.Pages.StartPage);
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.ToggleCodeView),
+      );
+
+      controller.setCurrentPageForTesting(
+          RecorderController.Pages.AllRecordingsPage,
+      );
+      assert.isFalse(
+          controller.isActionPossible(RecorderActions.ToggleCodeView),
+      );
+    });
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/components/BUILD.gn b/test/unittests/front_end/panels/recorder/components/BUILD.gn
new file mode 100644
index 0000000..d274fbb
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/BUILD.gn
@@ -0,0 +1,28 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../../../third_party/typescript/typescript.gni")
+
+ts_library("components") {
+  testonly = true
+  sources = [
+    "create-recording-view_test.ts",
+    "recording-list-view_test.ts",
+    "recording-view_test.ts",
+    "replay-button_test.ts",
+    "select-button_test.ts",
+    "split-view_test.ts",
+    "step-editor_test.ts",
+    "step-view_test.ts",
+  ]
+
+  deps = [
+    "../../../../../../front_end/ui/components/text_editor:bundle",
+    "../../../../../../test/unittests/front_end/helpers",
+    "../../../../../../front_end/panels/recorder/components:bundle",
+    "../../../../../../front_end/panels/recorder/models:bundle",
+    "../../../helpers",
+  ]
+}
diff --git a/test/unittests/front_end/panels/recorder/components/create-recording-view_test.ts b/test/unittests/front_end/panels/recorder/components/create-recording-view_test.ts
new file mode 100644
index 0000000..7a3deaf
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/create-recording-view_test.ts
@@ -0,0 +1,149 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Components from '../../../../../../front_end/panels/recorder/components/components.js';
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+import {
+  describeWithEnvironment,
+} from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as UI from '../../../../../../front_end/ui/legacy/legacy.js';
+
+describeWithEnvironment('CreateRecordingView', () => {
+  before(() => {
+    const actionRegistry = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+    UI.ShortcutRegistry.ShortcutRegistry.instance({
+      forceNew: true,
+      actionRegistry,
+    });
+  });
+  after(() => {
+    UI.ShortcutRegistry.ShortcutRegistry.removeInstance();
+    UI.ActionRegistry.ActionRegistry.removeInstance();
+  });
+
+  function createView() {
+    const view = new Components.CreateRecordingView.CreateRecordingView();
+    view.data = {
+      recorderSettings: new Models.RecorderSettings.RecorderSettings(),
+    };
+    view.connectedCallback();
+    return view;
+  }
+
+  it('should render create recording view', async () => {
+    const view = createView();
+    const input = view.shadowRoot?.querySelector(
+                      '#user-flow-name',
+                      ) as HTMLInputElement;
+    assert.ok(input);
+    const button = view.shadowRoot?.querySelector(
+                       'devtools-control-button',
+                       ) as Components.ControlButton.ControlButton;
+    assert.ok(button);
+    const  Promise<Components.CreateRecordingView.RecordingStartedEvent>(
+        resolve => {
+          view.addEventListener('recordingstarted', resolve, {once: true});
+        },
+    );
+    input.value = 'Test';
+    button.dispatchEvent(new Event('click'));
+    const event = await onceClicked;
+    assert.deepEqual(event.name, 'Test');
+  });
+
+  it('should dispatch recordingcancelled event on the close button click', async () => {
+    const view = createView();
+    const  Promise<Components.CreateRecordingView.RecordingCancelledEvent>(
+        resolve => {
+          view.addEventListener('recordingcancelled', resolve, {once: true});
+        },
+    );
+    const closeButton = view.shadowRoot?.querySelector(
+                            '[title="Cancel recording"]',
+                            ) as HTMLButtonElement;
+
+    closeButton.dispatchEvent(new Event('click'));
+    const event = await onceClicked;
+    assert.instanceOf(
+        event,
+        Components.CreateRecordingView.RecordingCancelledEvent,
+    );
+  });
+
+  it('should generate a default name', async () => {
+    const view = createView();
+    const input = view.shadowRoot?.querySelector(
+                      '#user-flow-name',
+                      ) as HTMLInputElement;
+    assert.isAtLeast(input.value.length, 'Recording'.length);
+  });
+
+  it('should remember the most recent selector attribute', async () => {
+    let view = createView();
+    let input = view.shadowRoot?.querySelector(
+                    '#selector-attribute',
+                    ) as HTMLInputElement;
+    assert.ok(input);
+    const button = view.shadowRoot?.querySelector(
+                       'devtools-control-button',
+                       ) as Components.ControlButton.ControlButton;
+    assert.ok(button);
+    const  Promise<Components.CreateRecordingView.RecordingStartedEvent>(
+        resolve => {
+          view.addEventListener('recordingstarted', resolve, {once: true});
+        },
+    );
+    input.value = 'data-custom-attribute';
+    button.dispatchEvent(new Event('click'));
+    await onceClicked;
+
+    view = createView();
+    input = view.shadowRoot?.querySelector(
+                '#selector-attribute',
+                ) as HTMLInputElement;
+    assert.ok(input);
+    assert.strictEqual(input.value, 'data-custom-attribute');
+  });
+
+  it('should remember recorded selector types', async () => {
+    let view = createView();
+
+    let checkboxes = view.shadowRoot?.querySelectorAll(
+                         '.selector-type input[type=checkbox]',
+                         ) as NodeListOf<HTMLInputElement>;
+    assert.strictEqual(checkboxes.length, 5);
+    const button = view.shadowRoot?.querySelector(
+                       'devtools-control-button',
+                       ) as Components.ControlButton.ControlButton;
+    assert.ok(button);
+    const  Promise<Components.CreateRecordingView.RecordingStartedEvent>(
+        resolve => {
+          view.addEventListener('recordingstarted', resolve, {once: true});
+        },
+    );
+    checkboxes[0].checked = false;
+    button.dispatchEvent(new Event('click'));
+    const event = await onceClicked;
+
+    assert.deepStrictEqual(event.selectorTypesToRecord, [
+      'aria',
+      'text',
+      'xpath',
+      'pierce',
+    ]);
+
+    view = createView();
+    checkboxes = view.shadowRoot?.querySelectorAll(
+                     '.selector-type input[type=checkbox]',
+                     ) as NodeListOf<HTMLInputElement>;
+    assert.strictEqual(checkboxes.length, 5);
+    assert.isFalse(checkboxes[0].checked);
+    assert.isTrue(checkboxes[1].checked);
+    assert.isTrue(checkboxes[2].checked);
+    assert.isTrue(checkboxes[3].checked);
+    assert.isTrue(checkboxes[4].checked);
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/components/recording-list-view_test.ts b/test/unittests/front_end/panels/recorder/components/recording-list-view_test.ts
new file mode 100644
index 0000000..21f633b
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/recording-list-view_test.ts
@@ -0,0 +1,94 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Components from '../../../../../../front_end/panels/recorder/components/components.js';
+
+import {
+  describeWithEnvironment,
+} from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {
+  dispatchClickEvent,
+  dispatchKeyDownEvent,
+} from '../../../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import * as Coordinator from '../../../../../../front_end/ui/components/render_coordinator/render_coordinator.js';
+import * as UI from '../../../../../../front_end/ui/legacy/legacy.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('RecordingListView', () => {
+  before(() => {
+    const actionRegistry = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+    UI.ShortcutRegistry.ShortcutRegistry.instance({
+      forceNew: true,
+      actionRegistry,
+    });
+  });
+  after(() => {
+    UI.ShortcutRegistry.ShortcutRegistry.removeInstance();
+    UI.ActionRegistry.ActionRegistry.removeInstance();
+  });
+
+  it('should open a recording on Enter', async () => {
+    const view = new Components.RecordingListView.RecordingListView();
+    view.connectedCallback();
+    view.recordings = [{storageName: 'storage-test', name: 'test'}];
+    await coordinator.done();
+    const recording = view.shadowRoot?.querySelector('.row') as HTMLDivElement;
+    assert.ok(recording);
+    const eventSent = new Promise<Components.RecordingListView.OpenRecordingEvent>(
+        resolve => {
+          view.addEventListener('openrecording', resolve, {once: true});
+        },
+    );
+    dispatchKeyDownEvent(recording, {key: 'Enter'});
+    const event = await eventSent;
+    assert.strictEqual(event.storageName, 'storage-test');
+  });
+
+  it('should delete a recording', async () => {
+    const view = new Components.RecordingListView.RecordingListView();
+    view.connectedCallback();
+    view.recordings = [{storageName: 'storage-test', name: 'test'}];
+    await coordinator.done();
+    const deleteButton = view.shadowRoot?.querySelector(
+                             '.delete-recording-button',
+                             ) as HTMLDivElement;
+    assert.ok(deleteButton);
+    const eventSent = new Promise<Components.RecordingListView.DeleteRecordingEvent>(
+        resolve => {
+          view.addEventListener('deleterecording', resolve, {once: true});
+        },
+    );
+    dispatchClickEvent(deleteButton);
+    const event = await eventSent;
+    assert.strictEqual(event.storageName, 'storage-test');
+  });
+
+  it('should not open a recording on Enter on the delete button', async () => {
+    const view = new Components.RecordingListView.RecordingListView();
+    view.connectedCallback();
+    view.recordings = [{storageName: 'storage-test', name: 'test'}];
+    await coordinator.done();
+    const deleteButton = view.shadowRoot?.querySelector(
+                             '.delete-recording-button',
+                             ) as HTMLDivElement;
+    assert.ok(deleteButton);
+    let forceResolve: Function|undefined;
+    const eventSent = new Promise<Components.RecordingListView.OpenRecordingEvent>(
+        resolve => {
+          forceResolve = resolve;
+          view.addEventListener('openrecording', resolve, {once: true});
+        },
+    );
+    dispatchKeyDownEvent(deleteButton, {key: 'Enter', bubbles: true});
+    const maybeEvent = await Promise.race([
+      eventSent,
+      new Promise(resolve => queueMicrotask(() => resolve('timeout'))),
+    ]);
+    assert.strictEqual(maybeEvent, 'timeout');
+    forceResolve?.();
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/components/recording-view_test.ts b/test/unittests/front_end/panels/recorder/components/recording-view_test.ts
new file mode 100644
index 0000000..0090df3
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/recording-view_test.ts
@@ -0,0 +1,255 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+import * as Components from '../../../../../../front_end/panels/recorder/components/components.js';
+import * as Converters from '../../../../../../front_end/panels/recorder/converters/converters.js';
+import type * as TextEditor from
+    '../../../../../../front_end/ui/components/text_editor/text_editor.js';
+import * as Host from '../../../../../../front_end/core/host/host.js';
+
+import {
+  describeWithEnvironment,
+} from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import {
+  dispatchClickEvent,
+  dispatchMouseOverEvent,
+  getEventPromise,
+} from '../../../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import * as Coordinator from '../../../../../../front_end/ui/components/render_coordinator/render_coordinator.js';
+import * as Menus from '../../../../../../front_end/ui/components/menus/menus.js';
+import * as UI from '../../../../../../front_end/ui/legacy/legacy.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('RecordingView', () => {
+  before(() => {
+    const actionRegistry = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
+    UI.ShortcutRegistry.ShortcutRegistry.instance({
+      forceNew: true,
+      actionRegistry,
+    });
+  });
+  after(() => {
+    UI.ShortcutRegistry.ShortcutRegistry.removeInstance();
+    UI.ActionRegistry.ActionRegistry.removeInstance();
+  });
+
+  const step = {type: Models.Schema.StepType.Scroll as const};
+  const section = {title: 'test', steps: [step], url: 'https://example.com'};
+  const userFlow = {title: 'test', steps: [step]};
+  const recorderSettingsMock = {
+    preferredCopyFormat: Models.ConverterIds.ConverterIds.JSON,
+  } as Models.RecorderSettings.RecorderSettings;
+
+  async function renderView(): Promise<Components.RecordingView.RecordingView> {
+    const view = new Components.RecordingView.RecordingView();
+    recorderSettingsMock.preferredCopyFormat = Models.ConverterIds.ConverterIds.JSON;
+    view.connectedCallback();
+    view.data = {
+      replayState: {isPlaying: false, isPausedOnBreakpoint: false},
+      isRecording: false,
+      recordingTogglingInProgress: false,
+      recording: userFlow,
+      currentStep: undefined,
+      currentError: undefined,
+      sections: [section],
+      settings: undefined,
+      recorderSettings: recorderSettingsMock,
+      lastReplayResult: undefined,
+      replayAllowed: true,
+      breakpointIndexes: new Set(),
+      builtInConverters: [
+        new Converters.JSONConverter.JSONConverter('  '),
+        new Converters.PuppeteerReplayConverter.PuppeteerReplayConverter('  '),
+      ],
+      extensionConverters: [],
+      replayExtensions: [],
+    };
+    await coordinator.done();
+    return view;
+  }
+
+  async function waitForTextEditor(
+      view: Components.RecordingView.RecordingView,
+      ): Promise<TextEditor.TextEditor.TextEditor> {
+    await getEventPromise(view, 'code-generated');
+    const textEditor = view.shadowRoot?.querySelector('devtools-text-editor');
+    assert.isNotNull(textEditor);
+    return textEditor as TextEditor.TextEditor.TextEditor;
+  }
+
+  function hoverOverScrollStep(
+      view: Components.RecordingView.RecordingView,
+      ): void {
+    const steps = view.shadowRoot?.querySelectorAll('devtools-step-view') || [];
+    assert.lengthOf(steps, 2);
+    dispatchMouseOverEvent(steps[1]);
+  }
+
+  function clickStep(view: Components.RecordingView.RecordingView) {
+    const steps = view.shadowRoot?.querySelectorAll('devtools-step-view') || [];
+    assert.lengthOf(steps, 2);
+    dispatchClickEvent(steps[1]);
+  }
+
+  function dispatchOnStep(
+      view: Components.RecordingView.RecordingView,
+      customEvent: Event,
+  ) {
+    const steps = view.shadowRoot?.querySelectorAll('devtools-step-view') || [];
+    assert.lengthOf(steps, 2);
+    steps[1].dispatchEvent(customEvent);
+  }
+
+  function clickShowCode(view: Components.RecordingView.RecordingView) {
+    const button = view.shadowRoot?.querySelector(
+                       '.show-code',
+                       ) as HTMLDivElement;
+    assert.ok(button);
+    dispatchClickEvent(button);
+  }
+
+  function clickHideCode(view: Components.RecordingView.RecordingView) {
+    const button = view.shadowRoot?.querySelector(
+                       '[title="Hide code"]',
+                       ) as HTMLDivElement;
+    assert.ok(button);
+    dispatchClickEvent(button);
+  }
+
+  async function waitForSplitViewToDissappear(
+      view: Components.RecordingView.RecordingView,
+      ): Promise<void> {
+    await getEventPromise(view, 'code-generated');
+    const splitView = view.shadowRoot?.querySelector('devtools-split-view');
+    assert.isNull(splitView);
+  }
+
+  async function changeCodeView(view: Components.RecordingView.RecordingView): Promise<void> {
+    const menu = view.shadowRoot?.querySelector(
+                     'devtools-select-menu',
+                     ) as Menus.SelectMenu.SelectMenu;
+    assert.ok(menu);
+
+    const event = new Menus.SelectMenu.SelectMenuItemSelectedEvent(Models.ConverterIds.ConverterIds.Replay);
+    menu.dispatchEvent(event);
+  }
+
+  it('should show code and highlight on hover', async () => {
+    const view = await renderView();
+
+    clickShowCode(view);
+
+    // Click is handled async, therefore, waiting for the text editor.
+    const textEditor = await waitForTextEditor(view);
+    assert.deepStrictEqual(textEditor.editor.state.selection.toJSON(), {
+      ranges: [{anchor: 0, head: 0}],
+      main: 0,
+    });
+
+    hoverOverScrollStep(view);
+    assert.deepStrictEqual(textEditor.editor.state.selection.toJSON(), {
+      ranges: [{anchor: 34, head: 68}],
+      main: 0,
+    });
+  });
+
+  it('should close code', async () => {
+    const view = await renderView();
+
+    clickShowCode(view);
+
+    // Click is handled async, therefore, waiting for the text editor.
+    await waitForTextEditor(view);
+
+    clickHideCode(view);
+
+    // Click is handled async, therefore, waiting for split view to be removed.
+    await waitForSplitViewToDissappear(view);
+  });
+
+  it('should copy the recording to clipboard via copy event', async () => {
+    await renderView();
+    const clipboardData = new DataTransfer();
+    const isCalled = sinon.promise();
+    const copyText = sinon
+                         .stub(
+                             Host.InspectorFrontendHost.InspectorFrontendHostInstance,
+                             'copyText',
+                             )
+                         .callsFake(() => {
+                           void isCalled.resolve(true);
+                         });
+    const event = new ClipboardEvent('copy', {clipboardData, bubbles: true});
+
+    document.body.dispatchEvent(event);
+
+    await isCalled;
+
+    assert.isTrue(
+        copyText.calledWith(JSON.stringify(userFlow, null, 2) + '\n'),
+    );
+  });
+
+  it('should copy a step to clipboard via copy event', async () => {
+    const view = await renderView();
+
+    clickStep(view);
+
+    const clipboardData = new DataTransfer();
+    const isCalled = sinon.promise();
+    const copyText = sinon
+                         .stub(
+                             Host.InspectorFrontendHost.InspectorFrontendHostInstance,
+                             'copyText',
+                             )
+                         .callsFake(() => {
+                           void isCalled.resolve(true);
+                         });
+    const event = new ClipboardEvent('copy', {clipboardData, bubbles: true});
+
+    document.body.dispatchEvent(event);
+
+    await isCalled;
+
+    assert.isTrue(copyText.calledWith(JSON.stringify(step, null, 2) + '\n'));
+  });
+
+  it('should copy a step to clipboard via custom event', async () => {
+    const view = await renderView();
+    const isCalled = sinon.promise();
+    const copyText = sinon
+                         .stub(
+                             Host.InspectorFrontendHost.InspectorFrontendHostInstance,
+                             'copyText',
+                             )
+                         .callsFake(() => {
+                           void isCalled.resolve(true);
+                         });
+    const event = new Components.StepView.CopyStepEvent(step);
+
+    dispatchOnStep(view, event);
+
+    await isCalled;
+
+    assert.isTrue(copyText.calledWith(JSON.stringify(step, null, 2) + '\n'));
+  });
+
+  it('should show code and change preferred copy method', async () => {
+    const view = await renderView();
+
+    clickShowCode(
+        view,
+    );
+
+    await waitForTextEditor(view);
+    await changeCodeView(view);
+    await waitForTextEditor(view);
+
+    assert.notEqual(recorderSettingsMock.preferredCopyFormat, Models.ConverterIds.ConverterIds.JSON);
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/components/replay-button_test.ts b/test/unittests/front_end/panels/recorder/components/replay-button_test.ts
new file mode 100644
index 0000000..f9a27a0
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/replay-button_test.ts
@@ -0,0 +1,109 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as RecorderComponents from '../../../../../../front_end/panels/recorder/components/components.js';
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+import {
+  describeWithEnvironment,
+} from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Coordinator from '../../../../../../front_end/ui/components/render_coordinator/render_coordinator.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('ReplayButton', () => {
+  let settings: Models.RecorderSettings.RecorderSettings;
+  async function createReplayButton() {
+    settings = new Models.RecorderSettings.RecorderSettings();
+    const component = new RecorderComponents.ReplayButton.ReplayButton();
+    component.data = {settings, replayExtensions: []};
+    component.connectedCallback();
+    await coordinator.done();
+
+    return component;
+  }
+
+  afterEach(() => {
+    settings.speed = Models.RecordingPlayer.PlayRecordingSpeed.Normal;
+  });
+
+  it('should change the button value when another option is selected in select menu', async () => {
+    const component = await createReplayButton();
+    const selectButton = component.shadowRoot?.querySelector(
+        'devtools-select-button',
+    );
+    assert.strictEqual(
+        selectButton?.value,
+        Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+    );
+
+    selectButton?.dispatchEvent(
+        new RecorderComponents.SelectButton.SelectButtonClickEvent(
+            Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+            ),
+    );
+    await coordinator.done();
+    assert.strictEqual(
+        selectButton?.value,
+        Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+    );
+  });
+
+  it('should emit startreplayevent on selectbuttonclick event', async () => {
+    const component = await createReplayButton();
+    const  Promise<RecorderComponents.ReplayButton.StartReplayEvent>(
+        resolve => {
+          component.addEventListener('startreplay', resolve, {once: true});
+        },
+    );
+
+    const selectButton = component.shadowRoot?.querySelector(
+        'devtools-select-button',
+    );
+    selectButton?.dispatchEvent(
+        new RecorderComponents.SelectButton.SelectButtonClickEvent(
+            Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+            ),
+    );
+
+    const event = await onceClicked;
+    assert.deepEqual(
+        event.speed,
+        Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+    );
+  });
+
+  it('should save the changed button when option is selected in select menu', async () => {
+    const component = await createReplayButton();
+    const selectButton = component.shadowRoot?.querySelector(
+        'devtools-select-button',
+    );
+
+    selectButton?.dispatchEvent(
+        new RecorderComponents.SelectButton.SelectButtonClickEvent(
+            Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+            ),
+    );
+
+    assert.strictEqual(
+        settings.speed,
+        Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+    );
+  });
+
+  it('should load the saved button on initial render', async () => {
+    settings.speed = Models.RecordingPlayer.PlayRecordingSpeed.Slow;
+
+    const component = await createReplayButton();
+
+    const selectButton = component.shadowRoot?.querySelector(
+        'devtools-select-button',
+    );
+    assert.strictEqual(
+        selectButton?.value,
+        Models.RecordingPlayer.PlayRecordingSpeed.Slow,
+    );
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/components/select-button_test.ts b/test/unittests/front_end/panels/recorder/components/select-button_test.ts
new file mode 100644
index 0000000..4601046
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/select-button_test.ts
@@ -0,0 +1,68 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as RecorderComponents from '../../../../../../front_end/panels/recorder/components/components.js';
+import * as Menus from '../../../../../../front_end/ui/components/menus/menus.js';
+
+import * as Coordinator from '../../../../../../front_end/ui/components/render_coordinator/render_coordinator.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describe('SelectButton', () => {
+  it('should emit selectbuttonclick event on button click', async () => {
+    const component = new RecorderComponents.SelectButton.SelectButton();
+    component.value = 'item1';
+    component.items = [
+      {value: 'item1', label: (): string => 'item1-label'},
+      {value: 'item2', label: (): string => 'item2-label'},
+    ];
+    component.connectedCallback();
+    await coordinator.done();
+    const  Promise<RecorderComponents.SelectButton.SelectButtonClickEvent>(
+        resolve => {
+          component.addEventListener('selectbuttonclick', resolve, {
+            once: true,
+          });
+        },
+    );
+
+    const button = component.shadowRoot?.querySelector('devtools-button');
+    assert.exists(button);
+    button?.click();
+
+    const event = await onceClicked;
+    assert.strictEqual(event.value, 'item1');
+  });
+
+  it('should emit selectbuttonclick event on item click in select menu', async () => {
+    const component = new RecorderComponents.SelectButton.SelectButton();
+    component.value = 'item1';
+    component.items = [
+      {value: 'item1', label: (): string => 'item1-label'},
+      {value: 'item2', label: (): string => 'item2-label'},
+    ];
+    component.connectedCallback();
+    await coordinator.done();
+    const  Promise<RecorderComponents.SelectButton.SelectButtonClickEvent>(
+        resolve => {
+          component.addEventListener('selectbuttonclick', resolve, {
+            once: true,
+          });
+        },
+    );
+
+    const selectMenu = component.shadowRoot?.querySelector(
+        'devtools-select-menu',
+    );
+    assert.exists(selectMenu);
+    selectMenu?.dispatchEvent(
+        new Menus.SelectMenu.SelectMenuItemSelectedEvent('item1'),
+    );
+
+    const event = await onceClicked;
+    assert.strictEqual(event.value, 'item1');
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/components/split-view_test.ts b/test/unittests/front_end/panels/recorder/components/split-view_test.ts
new file mode 100644
index 0000000..516d151
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/split-view_test.ts
@@ -0,0 +1,88 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Components from '../../../../../../front_end/panels/recorder/components/components.js';
+import * as Helpers from '../../../../../../test/unittests/front_end/helpers/DOMHelpers.js';  // eslint-disable-line rulesdir/es_modules_import
+
+describe('SplitView', () => {
+  it('should resize split view', async () => {
+    const view = new Components.SplitView.SplitView();
+    Helpers.renderElementIntoDOM(view);
+    view.style.width = '800px';
+    view.style.height = '600px';
+
+    const resizer = view.shadowRoot?.querySelector(
+                        '#resizer',
+                        ) as HTMLDivElement;
+    assert.ok(resizer);
+
+    assert.strictEqual(
+        view.style.getPropertyValue('--current-main-area-size'),
+        '60%',
+    );
+
+    let rect = resizer.getBoundingClientRect();
+    resizer.dispatchEvent(
+        new MouseEvent('mousedown', {
+          clientX: rect.x + rect.width / 2,
+          clientY: rect.y + rect.height / 2,
+        }),
+    );
+
+    rect = view.getBoundingClientRect();
+    window.dispatchEvent(
+        new MouseEvent('mousemove', {
+          clientX: rect.x + rect.width / 4,
+          clientY: rect.y + rect.height / 4,
+        }),
+    );
+
+    window.dispatchEvent(new MouseEvent('mouseup'));
+    // Exact value might be different based on the environment.
+    assert.notStrictEqual(
+        view.style.getPropertyValue('--current-main-area-size'),
+        '60%',
+    );
+  });
+
+  it('should change layout to vertical on resize to narrow view', () => {
+    const view = new Components.SplitView.SplitView();
+    Helpers.renderElementIntoDOM(view);
+    view.style.width = '800px';
+    view.style.height = '600px';
+
+    const resizer = view.shadowRoot?.querySelector(
+                        '#resizer',
+                        ) as HTMLDivElement;
+    assert.ok(resizer);
+
+    view.style.width = '600px';
+    view.style.height = '800px';
+
+    const rect = resizer.getBoundingClientRect();
+    assert.strictEqual(rect.width, 600);
+    assert.strictEqual(rect.height, 3);
+  });
+
+  it('should keep horizontal layout on short viewports', () => {
+    const view = new Components.SplitView.SplitView();
+    Helpers.renderElementIntoDOM(view);
+    view.style.width = '800px';
+    view.style.height = '600px';
+
+    const resizer = view.shadowRoot?.querySelector(
+                        '#resizer',
+                        ) as HTMLDivElement;
+    assert.ok(resizer);
+
+    view.style.width = '600px';
+    view.style.height = '550px';
+
+    const rect = resizer.getBoundingClientRect();
+    assert.strictEqual(rect.width, 3);
+    assert.strictEqual(rect.height, 550);
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/components/step-editor_test.ts b/test/unittests/front_end/panels/recorder/components/step-editor_test.ts
new file mode 100644
index 0000000..11c6c6b
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/step-editor_test.ts
@@ -0,0 +1,709 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import * as DOMHelpers from '../../../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+// eslint-disable-next-line rulesdir/es_modules_import
+import * as EnvironmentHelpers from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import type * as Components from '../../../../../../front_end/panels/recorder/components/components.js';
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+// eslint-disable-next-line rulesdir/es_modules_import
+import * as RecorderHelpers from '../../../helpers/RecorderHelpers.js';
+
+const {describeWithLocale} = EnvironmentHelpers;
+
+function getStepEditedPromise(editor: Components.StepEditor.StepEditor) {
+  return DOMHelpers
+      .getEventPromise<Components.StepEditor.StepEditedEvent>(
+          editor,
+          'stepedited',
+          )
+      .then(({data}) => data);
+}
+
+const triggerMicroTaskQueue = async(n = 1): Promise<void> => {
+  while (n > 0) {
+    --n;
+    await new Promise(resolve => setTimeout(resolve, 0));
+  }
+};
+
+describeWithLocale('StepEditor', () => {
+  async function renderEditor(
+      step: Models.Schema.Step,
+      ): Promise<Components.StepEditor.StepEditor> {
+    const editor = document.createElement('devtools-recorder-step-editor');
+    editor.step = structuredClone(step) as typeof editor.step;
+    DOMHelpers.renderElementIntoDOM(editor, {});
+    await editor.updateComplete;
+    return editor;
+  }
+
+  function getInputByAttribute(
+      editor: Components.StepEditor.StepEditor,
+      attribute: string,
+      ): Components.RecorderInput.RecorderInput {
+    const input = editor.renderRoot.querySelector(
+        `.attribute[data-attribute="${attribute}"] devtools-recorder-input`,
+    );
+    if (!input) {
+      throw new Error(`${attribute} devtools-recorder-input not found`);
+    }
+    return input as Components.RecorderInput.RecorderInput;
+  }
+
+  function getAllInputValues(
+      editor: Components.StepEditor.StepEditor,
+      ): string[] {
+    const result = [];
+    const inputs = editor.renderRoot.querySelectorAll(
+        'devtools-recorder-input',
+    );
+    for (const input of inputs) {
+      result.push(input.value);
+    }
+    return result;
+  }
+
+  async function addOptionalField(
+      editor: Components.StepEditor.StepEditor,
+      attribute: string,
+      ): Promise<void> {
+    const button = editor.renderRoot.querySelector(
+        `devtools-button.add-row[data-attribute="${attribute}"]`,
+    );
+    DOMHelpers.assertElement(button, HTMLElement);
+    button.click();
+    await triggerMicroTaskQueue();
+    await editor.updateComplete;
+  }
+
+  async function deleteOptionalField(
+      editor: Components.StepEditor.StepEditor,
+      attribute: string,
+      ): Promise<void> {
+    const button = editor.renderRoot.querySelector(
+        `devtools-button.delete-row[data-attribute="${attribute}"]`,
+    );
+    DOMHelpers.assertElement(button, HTMLElement);
+    button.click();
+    await triggerMicroTaskQueue();
+    await editor.updateComplete;
+  }
+
+  async function clickFrameLevelButton(
+      editor: Components.StepEditor.StepEditor,
+      className: string,
+      ): Promise<void> {
+    const button = editor.renderRoot.querySelector(
+        `.attribute[data-attribute="frame"] devtools-button${className}`,
+    );
+    DOMHelpers.assertElement(button, HTMLElement);
+    button.click();
+    await editor.updateComplete;
+  }
+
+  async function clickSelectorLevelButton(
+      editor: Components.StepEditor.StepEditor,
+      path: number[],
+      className: string,
+      ): Promise<void> {
+    const button = editor.renderRoot.querySelector(
+        `[data-selector-path="${path.join('.')}"] devtools-button${className}`,
+    );
+    DOMHelpers.assertElement(button, HTMLElement);
+    button.click();
+    await editor.updateComplete;
+  }
+
+  /**
+   * Extra button to be able to focus on it in tests to see how
+   * the step editor reacts when the focus moves outside of it.
+   */
+  function createFocusOutsideButton() {
+    const button = document.createElement('button');
+    button.innerText = 'click';
+    DOMHelpers.renderElementIntoDOM(button, {allowMultipleChildren: true});
+
+    return {
+      focus() {
+        button.focus();
+      },
+    };
+  }
+
+  beforeEach(() => {
+    RecorderHelpers.installMocksForRecordingPlayer();
+  });
+
+  it('should edit step type', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Click,
+      selectors: [['.cls']],
+      offsetX: 1,
+      offsetY: 1,
+    });
+    const step = getStepEditedPromise(editor);
+
+    const input = getInputByAttribute(editor, 'type');
+    input.focus();
+    input.value = 'change';
+    await input.updateComplete;
+
+    input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'Enter',
+          bubbles: true,
+          composed: true,
+        }),
+    );
+    await editor.updateComplete;
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.Change,
+      selectors: ['.cls'],
+      value: 'Value',
+    });
+    assert.deepStrictEqual(getAllInputValues(editor), [
+      'change',
+      '.cls',
+      'Value',
+    ]);
+  });
+
+  it('should edit step type via dropdown', async () => {
+    const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+    const step = getStepEditedPromise(editor);
+
+    const input = getInputByAttribute(editor, 'type');
+    input.focus();
+    input.value = '';
+    await input.updateComplete;
+
+    // Use the drop down.
+    input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'ArrowDown',
+          bubbles: true,
+          composed: true,
+        }),
+    );
+    input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'Enter',
+          bubbles: true,
+          composed: true,
+        }),
+    );
+    await editor.updateComplete;
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.Click,
+      selectors: ['.cls'],
+      offsetX: 1,
+      offsetY: 1,
+    });
+    assert.deepStrictEqual(getAllInputValues(editor), [
+      'click',
+      '.cls',
+      '1',
+      '1',
+    ]);
+  });
+
+  it('should edit other attributes', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.CustomStep,
+      name: 'test',
+      parameters: {},
+    });
+    const step = getStepEditedPromise(editor);
+
+    const input = getInputByAttribute(editor, 'parameters');
+    input.focus();
+    input.value = '{"custom":"test"}';
+    await input.updateComplete;
+
+    input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'Enter',
+          bubbles: true,
+          composed: true,
+        }),
+    );
+    await editor.updateComplete;
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.CustomStep,
+      name: 'test',
+      parameters: {custom: 'test'},
+    });
+    assert.deepStrictEqual(getAllInputValues(editor), [
+      'customStep',
+      'test',
+      '{"custom":"test"}',
+    ]);
+  });
+
+  it('should close dropdown on Enter', async () => {
+    const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+
+    const input = getInputByAttribute(editor, 'type');
+    input.focus();
+    input.value = '';
+    await input.updateComplete;
+
+    const suggestions = input.renderRoot.querySelector(
+        'devtools-suggestion-box',
+    );
+    if (!suggestions) {
+      throw new Error('Failed to find element');
+    }
+    assert.strictEqual(
+        window.getComputedStyle(suggestions).visibility,
+        'visible',
+    );
+
+    input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'Enter',
+          bubbles: true,
+          composed: true,
+        }),
+    );
+    assert.strictEqual(
+        window.getComputedStyle(suggestions).visibility,
+        'hidden',
+    );
+  });
+
+  it('should close dropdown on focus elsewhere', async () => {
+    const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+    const button = createFocusOutsideButton();
+
+    const input = getInputByAttribute(editor, 'type');
+    input.focus();
+    input.value = '';
+    await input.updateComplete;
+
+    const suggestions = input.renderRoot.querySelector(
+        'devtools-suggestion-box',
+    );
+    if (!suggestions) {
+      throw new Error('Failed to find element');
+    }
+    assert.strictEqual(
+        window.getComputedStyle(suggestions).visibility,
+        'visible',
+    );
+
+    button.focus();
+    assert.strictEqual(
+        window.getComputedStyle(suggestions).visibility,
+        'hidden',
+    );
+  });
+
+  it('should add optional fields', async () => {
+    const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+    const step = getStepEditedPromise(editor);
+
+    await addOptionalField(editor, 'x');
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.Scroll,
+      x: 0,
+    });
+    assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '0']);
+  });
+
+  it('should add the duration field', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Click,
+      offsetX: 1,
+      offsetY: 1,
+      selectors: ['.cls'],
+    });
+    const step = getStepEditedPromise(editor);
+
+    await addOptionalField(editor, 'duration');
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.Click,
+      offsetX: 1,
+      offsetY: 1,
+      selectors: ['.cls'],
+      duration: 50,
+    });
+    assert.deepStrictEqual(getAllInputValues(editor), [
+      'click',
+      '.cls',
+      '1',
+      '1',
+      '50',
+    ]);
+  });
+
+  it('should add the parameters field', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.WaitForElement,
+      selectors: ['.cls'],
+    });
+    const step = getStepEditedPromise(editor);
+
+    await addOptionalField(editor, 'properties');
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.WaitForElement,
+      selectors: ['.cls'],
+      properties: {},
+    });
+    assert.deepStrictEqual(getAllInputValues(editor), [
+      'waitForElement',
+      '.cls',
+      '{}',
+    ]);
+  });
+
+  it('should edit timeout fields', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Navigate,
+      url: 'https://example.com',
+    });
+    const step = getStepEditedPromise(editor);
+
+    await addOptionalField(editor, 'timeout');
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.Navigate,
+      url: 'https://example.com',
+      timeout: 5000,
+    });
+    assert.deepStrictEqual(getAllInputValues(editor), [
+      'navigate',
+      'https://example.com',
+      '5000',
+    ]);
+  });
+
+  it('should delete optional fields', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Scroll,
+      x: 1,
+    });
+    const step = getStepEditedPromise(editor);
+
+    await deleteOptionalField(editor, 'x');
+
+    assert.deepStrictEqual(await step, {type: Models.Schema.StepType.Scroll});
+    assert.deepStrictEqual(getAllInputValues(editor), ['scroll']);
+  });
+
+  it('should add/remove frames', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Scroll,
+      frame: [0],
+    });
+    {
+      const step = getStepEditedPromise(editor);
+
+      await clickFrameLevelButton(editor, '.add-frame');
+
+      assert.deepStrictEqual(await step, {
+        type: Models.Schema.StepType.Scroll,
+        frame: [0, 0],
+      });
+      assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '0', '0']);
+
+      assert.isTrue(
+          editor.shadowRoot?.activeElement?.matches(
+              'devtools-recorder-input[data-path="frame.1"]',
+              ),
+      );
+    }
+    {
+      const step = getStepEditedPromise(editor);
+
+      await clickFrameLevelButton(editor, '.remove-frame');
+
+      assert.deepStrictEqual(await step, {
+        type: Models.Schema.StepType.Scroll,
+        frame: [0],
+      });
+      assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '0']);
+
+      assert.isTrue(
+          editor.shadowRoot?.activeElement?.matches(
+              'devtools-recorder-input[data-path="frame.0"]',
+              ),
+      );
+    }
+  });
+
+  it('should add/remove selector parts', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Scroll,
+      selectors: [['.part1']],
+    });
+
+    {
+      const step = getStepEditedPromise(editor);
+
+      await clickSelectorLevelButton(editor, [0, 0], '.add-selector-part');
+
+      assert.deepStrictEqual(await step, {
+        type: Models.Schema.StepType.Scroll,
+        selectors: [['.part1', '.cls']],
+      });
+      assert.deepStrictEqual(getAllInputValues(editor), [
+        'scroll',
+        '.part1',
+        '.cls',
+      ]);
+
+      assert.isTrue(
+          editor.shadowRoot?.activeElement?.matches(
+              'devtools-recorder-input[data-path="selectors.0.1"]',
+              ),
+      );
+    }
+
+    {
+      const step = getStepEditedPromise(editor);
+
+      await clickSelectorLevelButton(editor, [0, 0], '.remove-selector-part');
+
+      assert.deepStrictEqual(await step, {
+        type: Models.Schema.StepType.Scroll,
+        selectors: ['.cls'],
+      });
+      assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '.cls']);
+
+      assert.isTrue(
+          editor.shadowRoot?.activeElement?.matches(
+              'devtools-recorder-input[data-path="selectors.0.0"]',
+              ),
+      );
+    }
+  });
+
+  it('should add/remove selectors', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Scroll,
+      selectors: [['.part1']],
+    });
+    {
+      const step = getStepEditedPromise(editor);
+
+      await clickSelectorLevelButton(editor, [0], '.add-selector');
+
+      assert.deepStrictEqual(await step, {
+        type: Models.Schema.StepType.Scroll,
+        selectors: ['.part1', '.cls'],
+      });
+      assert.deepStrictEqual(getAllInputValues(editor), [
+        'scroll',
+        '.part1',
+        '.cls',
+      ]);
+      assert.isTrue(
+          editor.shadowRoot?.activeElement?.matches(
+              'devtools-recorder-input[data-path="selectors.1.0"]',
+              ),
+      );
+    }
+    {
+      const step = getStepEditedPromise(editor);
+
+      await clickSelectorLevelButton(editor, [1], '.remove-selector');
+
+      assert.deepStrictEqual(await step, {
+        type: Models.Schema.StepType.Scroll,
+        selectors: ['.part1'],
+      });
+      assert.deepStrictEqual(getAllInputValues(editor), ['scroll', '.part1']);
+      assert.isTrue(
+          editor.shadowRoot?.activeElement?.matches(
+              'devtools-recorder-input[data-path="selectors.0.0"]',
+              ),
+      );
+    }
+  });
+
+  it('should become readonly if disabled', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Scroll,
+      selectors: [['.part1']],
+    });
+    editor.disabled = true;
+    await editor.updateComplete;
+
+    for (const input of editor.renderRoot.querySelectorAll(
+             'devtools-recorder-input',
+             )) {
+      assert.isTrue(input.disabled);
+    }
+  });
+
+  it('clears text selection when navigating away from devtools-recorder-input', async () => {
+    const editor = await renderEditor({type: Models.Schema.StepType.Scroll});
+
+    // Clicking on the type devtools-recorder-input should select the entire text in the field.
+    const input = getInputByAttribute(editor, 'type');
+    input.focus();
+    input.click();
+    assert.strictEqual(window.getSelection()?.toString(), 'scroll');
+
+    // Navigating away should remove the selection.
+    DOMHelpers.dispatchKeyDownEvent(input, {
+      key: 'Enter',
+      bubbles: true,
+      composed: true,
+    });
+    assert.strictEqual(window.getSelection()?.toString(), '');
+  });
+
+  it('should add an attribute after another\'s deletion', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.WaitForElement,
+      selectors: [['.cls']],
+    });
+
+    await addOptionalField(editor, 'operator');
+    await deleteOptionalField(editor, 'operator');
+    const step = getStepEditedPromise(editor);
+    await addOptionalField(editor, 'count');
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.WaitForElement,
+      selectors: ['.cls'],
+      count: 1,
+    });
+    assert.deepStrictEqual(getAllInputValues(editor), [
+      'waitForElement',
+      '.cls',
+      '1',
+    ]);
+  });
+
+  it('should edit asserted events', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.Navigate,
+      url: 'www.example.com',
+      assertedEvents: [{
+        type: 'navigation' as Models.Schema.AssertedEventType,
+        title: 'Test',
+        url: 'www.example.com',
+      }],
+    });
+
+    const step = getStepEditedPromise(editor);
+
+    const input = getInputByAttribute(editor, 'assertedEvents');
+    input.focus();
+    input.value = 'None';
+    await input.updateComplete;
+
+    input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'Enter',
+          bubbles: true,
+          composed: true,
+        }),
+    );
+    await editor.updateComplete;
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.Navigate,
+      url: 'www.example.com',
+      assertedEvents: [{
+        type: 'navigation' as Models.Schema.AssertedEventType,
+        title: 'None',
+        url: 'www.example.com',
+      }],
+    });
+  });
+
+  it('should add/remove attribute assertion', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.WaitForElement,
+      selectors: ['.part1'],
+      attributes: {
+        a: 'b',
+      },
+    });
+    {
+      const step = getStepEditedPromise(editor);
+
+      editor.renderRoot.querySelectorAll<HTMLElement>('.add-attribute-assertion')[0]?.click();
+
+      assert.deepStrictEqual(await step, {
+        type: Models.Schema.StepType.WaitForElement,
+        selectors: ['.part1'],
+        attributes: {a: 'b', attribute: 'value'},
+      });
+      assert.deepStrictEqual(getAllInputValues(editor), [
+        'waitForElement',
+        '.part1',
+        'a',
+        'b',
+        'attribute',
+        'value',
+      ]);
+    }
+    {
+      const step = getStepEditedPromise(editor);
+
+      editor.renderRoot.querySelectorAll<HTMLElement>('.remove-attribute-assertion')[1]?.click();
+
+      assert.deepStrictEqual(await step, {
+        type: Models.Schema.StepType.WaitForElement,
+        selectors: ['.part1'],
+        attributes: {a: 'b'},
+      });
+      assert.deepStrictEqual(getAllInputValues(editor), [
+        'waitForElement',
+        '.part1',
+        'a',
+        'b',
+      ]);
+    }
+  });
+
+  it('should edit attribute assertion', async () => {
+    const editor = await renderEditor({
+      type: Models.Schema.StepType.WaitForElement,
+      selectors: ['.part1'],
+      attributes: {
+        a: 'b',
+      },
+    });
+
+    const step = getStepEditedPromise(editor);
+
+    const input = getInputByAttribute(editor, 'attributes');
+    input.focus();
+    input.value = 'innerText';
+    await input.updateComplete;
+
+    input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'Enter',
+          bubbles: true,
+          composed: true,
+        }),
+    );
+    await editor.updateComplete;
+
+    assert.deepStrictEqual(await step, {
+      type: Models.Schema.StepType.WaitForElement,
+      selectors: ['.part1'],
+      attributes: {
+        innerText: 'b',
+      },
+    });
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/components/step-view_test.ts b/test/unittests/front_end/panels/recorder/components/step-view_test.ts
new file mode 100644
index 0000000..e436c1d
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/components/step-view_test.ts
@@ -0,0 +1,264 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+import * as Converters from '../../../../../../front_end/panels/recorder/converters/converters.js';
+import * as Components from '../../../../../../front_end/panels/recorder/components/components.js';
+import * as Menus from '../../../../../../front_end/ui/components/menus/menus.js';
+import type * as Button from '../../../../../../front_end/ui/components/buttons/buttons.js';
+import * as Coordinator from '../../../../../../front_end/ui/components/render_coordinator/render_coordinator.js';
+
+import {
+  dispatchClickEvent,
+  getEventPromise,
+} from '../../../../../../test/unittests/front_end/helpers/DOMHelpers.js';
+import {
+  describeWithEnvironment,
+} from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+describeWithEnvironment('StepView', () => {
+  const step = {type: Models.Schema.StepType.Scroll as const};
+  const section = {title: 'test', steps: [step], url: 'https://example.com'};
+
+  async function createStepView(
+      opts: Partial<Components.StepView.StepViewData> = {},
+      ): Promise<Components.StepView.StepView> {
+    const view = new Components.StepView.StepView();
+    view.data = {
+      step: opts.step !== undefined ? step : undefined,
+      section: opts.section !== undefined ? section : undefined,
+      state: Components.StepView.State.Default,
+      isEndOfGroup: opts.isEndOfGroup ?? false,
+      isStartOfGroup: opts.isStartOfGroup ?? false,
+      isFirstSection: opts.isFirstSection ?? false,
+      isLastSection: opts.isLastSection ?? false,
+      stepIndex: opts.stepIndex ?? 0,
+      sectionIndex: opts.sectionIndex ?? 0,
+      isRecording: opts.isRecording ?? false,
+      isPlaying: opts.isPlaying ?? false,
+      hasBreakpoint: opts.hasBreakpoint ?? false,
+      removable: opts.removable ?? false,
+      builtInConverters: opts.builtInConverters ||
+          [
+            new Converters.JSONConverter.JSONConverter('  '),
+          ],
+      extensionConverters: opts.extensionConverters || [],
+      isSelected: opts.isSelected ?? false,
+      recorderSettings: new Models.RecorderSettings.RecorderSettings(),
+    };
+    view.connectedCallback();
+    await coordinator.done();
+    return view;
+  }
+
+  describe('Step and section actions menu', () => {
+    it('should open actions menu', async () => {
+      const view = await createStepView({step});
+      assert.notOk(
+          view.shadowRoot?.querySelector('devtools-menu[has-open-dialog]'),
+      );
+
+      const button = view.shadowRoot?.querySelector(
+                         '.step-actions',
+                         ) as Button.Button.Button;
+      assert.ok(button);
+
+      dispatchClickEvent(button);
+      await coordinator.done();
+
+      assert.ok(
+          view.shadowRoot?.querySelector('devtools-menu[has-open-dialog]'),
+      );
+    });
+
+    it('should dispatch "AddStep before" events on steps', async () => {
+      const view = await createStepView({step});
+
+      const menu = view.shadowRoot?.querySelector(
+                       '.step-actions + devtools-menu',
+                       ) as Menus.Menu.Menu;
+      assert.ok(menu);
+      const eventPromise = getEventPromise<Components.StepView.AddStep>(
+          view,
+          'addstep',
+      );
+      menu.dispatchEvent(
+          new Menus.Menu.MenuItemSelectedEvent('add-step-before'),
+      );
+      const event = await eventPromise;
+
+      assert.strictEqual(event.position, 'before');
+      assert.deepStrictEqual(event.stepOrSection, step);
+    });
+
+    it('should dispatch "AddStep before" events on steps', async () => {
+      const view = await createStepView({section});
+
+      const menu = view.shadowRoot?.querySelector(
+                       '.step-actions + devtools-menu',
+                       ) as Menus.Menu.Menu;
+      assert.ok(menu);
+      const eventPromise = getEventPromise<Components.StepView.AddStep>(
+          view,
+          'addstep',
+      );
+      menu.dispatchEvent(
+          new Menus.Menu.MenuItemSelectedEvent('add-step-before'),
+      );
+      const event = await eventPromise;
+
+      assert.strictEqual(event.position, 'before');
+      assert.deepStrictEqual(event.stepOrSection, section);
+    });
+
+    it('should dispatch "AddStep after" events on steps', async () => {
+      const view = await createStepView({step});
+
+      const menu = view.shadowRoot?.querySelector(
+                       '.step-actions + devtools-menu',
+                       ) as Menus.Menu.Menu;
+      assert.ok(menu);
+      const eventPromise = getEventPromise<Components.StepView.AddStep>(
+          view,
+          'addstep',
+      );
+      menu.dispatchEvent(
+          new Menus.Menu.MenuItemSelectedEvent('add-step-after'),
+      );
+      const event = await eventPromise;
+
+      assert.strictEqual(event.position, 'after');
+      assert.deepStrictEqual(event.stepOrSection, step);
+    });
+
+    it('should dispatch "Remove steps" events on steps', async () => {
+      const view = await createStepView({step});
+
+      const menu = view.shadowRoot?.querySelector(
+                       '.step-actions + devtools-menu',
+                       ) as Menus.Menu.Menu;
+      assert.ok(menu);
+      const eventPromise = getEventPromise<Components.StepView.RemoveStep>(
+          view,
+          'removestep',
+      );
+      menu.dispatchEvent(
+          new Menus.Menu.MenuItemSelectedEvent('remove-step'),
+      );
+      const event = await eventPromise;
+
+      assert.deepStrictEqual(event.step, step);
+    });
+
+    it('should dispatch "Add breakpoint" event on steps', async () => {
+      const view = await createStepView({step});
+
+      const menu = view.shadowRoot?.querySelector(
+                       '.step-actions + devtools-menu',
+                       ) as Menus.Menu.Menu;
+      assert.ok(menu);
+      const eventPromise = getEventPromise<Components.StepView.AddBreakpointEvent>(
+          view,
+          'addbreakpoint',
+      );
+      menu.dispatchEvent(
+          new Menus.Menu.MenuItemSelectedEvent('add-breakpoint'),
+      );
+      const event = await eventPromise;
+
+      assert.deepStrictEqual(event.index, 0);
+    });
+
+    it('should dispatch "Remove breakpoint" event on steps', async () => {
+      const view = await createStepView({step});
+
+      const menu = view.shadowRoot?.querySelector(
+                       '.step-actions + devtools-menu',
+                       ) as Menus.Menu.Menu;
+      assert.ok(menu);
+      const eventPromise = getEventPromise<Components.StepView.AddBreakpointEvent>(
+          view,
+          'removebreakpoint',
+      );
+      menu.dispatchEvent(
+          new Menus.Menu.MenuItemSelectedEvent('remove-breakpoint'),
+      );
+      const event = await eventPromise;
+
+      assert.deepStrictEqual(event.index, 0);
+    });
+  });
+
+  describe('Breakpoint events', () => {
+    it('should dispatch "Add breakpoint" event on breakpoint icon click if there is not a breakpoint on the step',
+       async () => {
+         const view = await createStepView({step});
+         const breakpointIcon = view.shadowRoot?.querySelector('.breakpoint-icon');
+         const eventPromise = getEventPromise<Components.StepView.AddBreakpointEvent>(
+             view,
+             'addbreakpoint',
+         );
+         assert.isOk(breakpointIcon);
+
+         breakpointIcon?.dispatchEvent(new Event('click'));
+         const event = await eventPromise;
+
+         assert.deepStrictEqual(event.index, 0);
+       });
+
+    it('should dispatch "Remove breakpoint" event on breakpoint icon click if there already is a breakpoint on the step',
+       async () => {
+         const view = await createStepView({hasBreakpoint: true, step});
+         const breakpointIcon = view.shadowRoot?.querySelector('.breakpoint-icon');
+         const eventPromise = getEventPromise<Components.StepView.RemoveBreakpointEvent>(
+             view,
+             'removebreakpoint',
+         );
+
+         breakpointIcon?.dispatchEvent(new Event('click'));
+         const event = await eventPromise;
+
+         assert.deepStrictEqual(event.index, 0);
+       });
+  });
+
+  describe('Copy steps', () => {
+    it('should copy a step to clipboard', async () => {
+      const view = await createStepView({step});
+      const menu = view.shadowRoot?.querySelector(
+                       '.step-actions + devtools-menu',
+                       ) as Menus.Menu.Menu;
+      assert.ok(menu);
+
+      const isCalled = sinon.promise();
+      view.addEventListener(Components.StepView.CopyStepEvent.eventName, () => {
+        void isCalled.resolve(true);
+      });
+      menu.dispatchEvent(
+          new Menus.Menu.MenuItemSelectedEvent('copy-step-as-json'),
+      );
+
+      const called = await isCalled;
+
+      assert.isTrue(called);
+    });
+  });
+
+  describe('Selection', () => {
+    it('should render timeline as selected if isSelected = true', async () => {
+      const view = await createStepView({step, isSelected: true});
+      assert.ok(view);
+      const section = view.shadowRoot?.querySelector(
+          'devtools-timeline-section',
+      );
+      assert.ok(section);
+      const div = section?.shadowRoot?.querySelector('div');
+      assert.isTrue(div?.classList.contains('is-selected'));
+    });
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/converters/BUILD.gn b/test/unittests/front_end/panels/recorder/converters/BUILD.gn
new file mode 100644
index 0000000..85b69f4
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/converters/BUILD.gn
@@ -0,0 +1,21 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../../../third_party/typescript/typescript.gni")
+
+ts_library("converters") {
+  testonly = true
+  sources = [
+    "LighthouseConverter_test.ts",
+    "PuppeteerConverter_test.ts",
+    "PuppeteerReplayConverter_test.ts",
+  ]
+
+  deps = [
+    "../../../../../../test/unittests/front_end/helpers",
+    "../../../../../../front_end/panels/recorder/converters:bundle",
+    "../../../../../../front_end/panels/recorder/models:bundle",
+  ]
+}
diff --git a/test/unittests/front_end/panels/recorder/converters/LighthouseConverter_test.ts b/test/unittests/front_end/panels/recorder/converters/LighthouseConverter_test.ts
new file mode 100644
index 0000000..2cc849e
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/converters/LighthouseConverter_test.ts
@@ -0,0 +1,86 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+import * as Converters from '../../../../../../front_end/panels/recorder/converters/converters.js';
+
+describe('LighthouseConverter', () => {
+  it('should stringify a flow', async () => {
+    const converter = new Converters.LighthouseConverter.LighthouseConverter(
+        '  ',
+    );
+    const [result, sourceMap] = await converter.stringify({
+      title: 'test',
+      steps: [
+        {type: Models.Schema.StepType.Navigate, url: 'https://example.com'},
+        {type: Models.Schema.StepType.Scroll, selectors: [['.cls']]},
+      ],
+    });
+    assert.isTrue(
+        result.startsWith(`const fs = require('fs');
+const puppeteer = require('puppeteer'); // v13.0.0 or later
+
+(async () => {
+  const browser = await puppeteer.launch();
+  const page = await browser.newPage();
+  const timeout = 5000;
+  page.setDefaultTimeout(timeout);
+
+  const lhApi = await import('lighthouse'); // v10.0.0 or later
+  const flags = {
+    screenEmulation: {
+      disabled: true
+    }
+  }
+  const config = lhApi.desktopConfig;
+  const lhFlow = await lhApi.startFlow(page, {name: 'test', config, flags});
+  await lhFlow.startNavigation();
+  {
+    const targetPage = page;
+    await targetPage.goto('https://example.com');
+  }
+  await lhFlow.endNavigation();
+  await lhFlow.startTimespan();
+  {
+    const targetPage = page;
+    await scrollIntoViewIfNeeded([
+      [
+        '.cls'
+      ]
+    ], targetPage, timeout);
+    const element = await waitForSelectors([
+      [
+        '.cls'
+      ]
+    ], targetPage, { timeout, visible: true });
+    await element.evaluate((el, x, y) => { el.scrollTop = y; el.scrollLeft = x; }, undefined, undefined);
+  }
+  await lhFlow.endTimespan();
+  const lhFlowReport = await lhFlow.generateReport();
+  fs.writeFileSync(__dirname + '/flow.report.html', lhFlowReport)
+
+  await browser.close();`),
+    );
+    assert.deepStrictEqual(sourceMap, [1, 17, 6, 23, 15]);
+  });
+
+  it('should stringify a step', async () => {
+    const converter = new Converters.LighthouseConverter.LighthouseConverter(
+        '  ',
+    );
+    const result = await converter.stringifyStep({
+      type: Models.Schema.StepType.Scroll,
+    });
+    assert.strictEqual(
+        result,
+        `{
+  const targetPage = page;
+  await targetPage.evaluate((x, y) => { window.scroll(x, y); }, undefined, undefined)
+}
+`,
+    );
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/converters/PuppeteerConverter_test.ts b/test/unittests/front_end/panels/recorder/converters/PuppeteerConverter_test.ts
new file mode 100644
index 0000000..28804ad
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/converters/PuppeteerConverter_test.ts
@@ -0,0 +1,64 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+import * as Converters from '../../../../../../front_end/panels/recorder/converters/converters.js';
+
+describe('PuppeteerConverter', () => {
+  it('should stringify a flow', async () => {
+    const converter = new Converters.PuppeteerConverter.PuppeteerConverter(
+        '  ',
+    );
+    const [result, sourceMap] = await converter.stringify({
+      title: 'test',
+      steps: [{type: Models.Schema.StepType.Scroll, selectors: [['.cls']]}],
+    });
+    assert.isTrue(
+        result.startsWith(`const puppeteer = require('puppeteer'); // v13.0.0 or later
+
+(async () => {
+  const browser = await puppeteer.launch();
+  const page = await browser.newPage();
+  const timeout = 5000;
+  page.setDefaultTimeout(timeout);
+
+  {
+    const targetPage = page;
+    await scrollIntoViewIfNeeded([
+      [
+        '.cls'
+      ]
+    ], targetPage, timeout);
+    const element = await waitForSelectors([
+      [
+        '.cls'
+      ]
+    ], targetPage, { timeout, visible: true });
+    await element.evaluate((el, x, y) => { el.scrollTop = y; el.scrollLeft = x; }, undefined, undefined);
+  }
+
+  await browser.close();`),
+    );
+    assert.deepStrictEqual(sourceMap, [1, 8, 14]);
+  });
+
+  it('should stringify a step', async () => {
+    const converter = new Converters.PuppeteerConverter.PuppeteerConverter(
+        '  ',
+    );
+    const result = await converter.stringifyStep({
+      type: Models.Schema.StepType.Scroll,
+    });
+    assert.strictEqual(
+        result,
+        `{
+  const targetPage = page;
+  await targetPage.evaluate((x, y) => { window.scroll(x, y); }, undefined, undefined)
+}
+`,
+    );
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/converters/PuppeteerReplayConverter_test.ts b/test/unittests/front_end/panels/recorder/converters/PuppeteerReplayConverter_test.ts
new file mode 100644
index 0000000..31a995d
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/converters/PuppeteerReplayConverter_test.ts
@@ -0,0 +1,60 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+import * as Converters from '../../../../../../front_end/panels/recorder/converters/converters.js';
+
+describe('PuppeteerReplayConverter', () => {
+  it('should stringify a flow', async () => {
+    const converter = new Converters.PuppeteerReplayConverter.PuppeteerReplayConverter('  ');
+    const [result, sourceMap] = await converter.stringify({
+      title: 'test',
+      steps: [{type: Models.Schema.StepType.Scroll, selectors: [['.cls']]}],
+    });
+    assert.strictEqual(
+        result,
+        `import url from 'url';
+import { createRunner } from '@puppeteer/replay';
+
+export async function run(extension) {
+  const runner = await createRunner(extension);
+
+  await runner.runBeforeAllSteps();
+
+  await runner.runStep({
+    type: 'scroll',
+    selectors: [
+      [
+        '.cls'
+      ]
+    ]
+  });
+
+  await runner.runAfterAllSteps();
+}
+
+if (process && import.meta.url === url.pathToFileURL(process.argv[1]).href) {
+  run()
+}
+`,
+    );
+    assert.deepStrictEqual(sourceMap, [1, 8, 8]);
+  });
+
+  it('should stringify a step', async () => {
+    const converter = new Converters.PuppeteerReplayConverter.PuppeteerReplayConverter('  ');
+    const result = await converter.stringifyStep({
+      type: Models.Schema.StepType.Scroll,
+    });
+    assert.strictEqual(
+        result,
+        `await runner.runStep({
+  type: 'scroll'
+});
+`,
+    );
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/injected/selectors/BUILD.gn b/test/unittests/front_end/panels/recorder/injected/selectors/BUILD.gn
new file mode 100644
index 0000000..5a4d8cc
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/injected/selectors/BUILD.gn
@@ -0,0 +1,13 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../../../../third_party/typescript/typescript.gni")
+
+ts_library("selectors") {
+  testonly = true
+  sources = [ "CSSSelector_test.ts" ]
+
+  deps = [ "../../../../../../../front_end/panels/recorder/injected" ]
+}
diff --git a/test/unittests/front_end/panels/recorder/injected/selectors/CSSSelector_test.ts b/test/unittests/front_end/panels/recorder/injected/selectors/CSSSelector_test.ts
new file mode 100644
index 0000000..323d8a9
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/injected/selectors/CSSSelector_test.ts
@@ -0,0 +1,41 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/* eslint-disable rulesdir/es_modules_import */
+
+import {findMinMax} from '../../../../../../../front_end/panels/recorder/injected/selectors/CSSSelector.js';
+
+describe('findMinMax', () => {
+  it('should work', () => {
+    const minmax = findMinMax([0, 10], {
+      inc(index: number): number {
+        return index + 1;
+      },
+      valueOf(index: number): number {
+        return index;
+      },
+      gte(value: number, index: number): boolean {
+        return value >= index;
+      },
+    });
+
+    assert.strictEqual(minmax, 9);
+  });
+
+  it('should work, non trivial', () => {
+    const minmax = findMinMax([0, 10], {
+      inc(index: number): number {
+        return index + 1;
+      },
+      valueOf(index: number): number {
+        return index;
+      },
+      gte(value: number, index: number): boolean {
+        return value >= Math.min(index, 5);
+      },
+    });
+
+    assert.strictEqual(minmax, 5);
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/models/BUILD.gn b/test/unittests/front_end/panels/recorder/models/BUILD.gn
new file mode 100644
index 0000000..f638ef9
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/BUILD.gn
@@ -0,0 +1,27 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../../../third_party/typescript/typescript.gni")
+
+ts_library("models") {
+  testonly = true
+  sources = [
+    "RecorderSettings_test.ts",
+    "RecorderShorcutHelper_test.ts",
+    "RecordingPlayer_test.ts",
+    "SchemaUtils_test.ts",
+    "ScreenshotUtils_test.ts",
+    "Section_test.ts",
+    "recording-storage_test.ts",
+    "screenshot-storage_test.ts",
+  ]
+
+  deps = [
+    "../../../../../../front_end/generated:protocol",
+    "../../../../../../test/unittests/front_end/helpers",
+    "../../../../../../front_end/panels/recorder/models:bundle",
+    "../../../helpers",
+  ]
+}
diff --git a/test/unittests/front_end/panels/recorder/models/RecorderSettings_test.ts b/test/unittests/front_end/panels/recorder/models/RecorderSettings_test.ts
new file mode 100644
index 0000000..bb645ee
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/RecorderSettings_test.ts
@@ -0,0 +1,65 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+import * as Common from '../../../../../../front_end/core/common/common.js';
+import {
+  describeWithEnvironment,
+} from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+describeWithEnvironment('RecorderSettings', () => {
+  let recorderSettings: Models.RecorderSettings.RecorderSettings;
+
+  beforeEach(() => {
+    recorderSettings = new Models.RecorderSettings.RecorderSettings();
+  });
+
+  it('should have correct default values', async () => {
+    assert.isTrue(recorderSettings.selectorAttribute === '');
+    assert.isTrue(
+        recorderSettings.speed === Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+    );
+    Object.values(Models.Schema.SelectorType).forEach(type => {
+      assert.isTrue(recorderSettings.getSelectorByType(type));
+    });
+  });
+
+  it('should get default Title', async () => {
+    const now = new Date('2022-12-01 15:30');
+    const clock = sinon.useFakeTimers(now.getTime());
+
+    assert.strictEqual(
+        recorderSettings.defaultTitle,
+        `Recording ${now.toLocaleDateString()} at ${now.toLocaleTimeString()}`,
+    );
+    clock.restore();
+  });
+
+  it('should save selector attribute change', () => {
+    const value = 'custom-selector';
+    recorderSettings.selectorAttribute = value;
+    assert.strictEqual(
+        Common.Settings.Settings.instance().settingForTest('recorderSelectorAttribute').get(),
+        value,
+    );
+  });
+
+  it('should save speed attribute change', () => {
+    recorderSettings.speed = Models.RecordingPlayer.PlayRecordingSpeed.ExtremelySlow;
+    assert.strictEqual(
+        Common.Settings.Settings.instance().settingForTest('recorderPanelReplaySpeed').get(),
+        Models.RecordingPlayer.PlayRecordingSpeed.ExtremelySlow,
+    );
+  });
+
+  it('should save selector type change', () => {
+    const selectorType = Models.Schema.SelectorType.CSS;
+    recorderSettings.setSelectorByType(selectorType, false);
+    assert.isFalse(
+        Common.Settings.Settings.instance().settingForTest(`recorder${selectorType}SelectorEnabled`).get(),
+    );
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/models/RecorderShorcutHelper_test.ts b/test/unittests/front_end/panels/recorder/models/RecorderShorcutHelper_test.ts
new file mode 100644
index 0000000..747f621d
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/RecorderShorcutHelper_test.ts
@@ -0,0 +1,60 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Host from '../../../../../../front_end/core/host/host.js';
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+
+describe('RecorderShortcutHelper', () => {
+  function waitFor(time: number) {
+    return new Promise(resolve => setTimeout(resolve, time));
+  }
+
+  function dispatchShortcut() {
+    const event = new KeyboardEvent('keyup', {
+      key: 'E',
+      ctrlKey: Host.Platform.isMac() ? false : true,
+      metaKey: Host.Platform.isMac() ? true : false,
+      bubbles: true,
+      composed: true,
+    });
+
+    document.dispatchEvent(event);
+  }
+
+  it('should wait for timeout', async () => {
+    const time = 10;
+    const helper = new Models.RecorderShortcutHelper.RecorderShortcutHelper(
+        time,
+    );
+    const stub = sinon.stub();
+
+    helper.handleShortcut(stub);
+    await waitFor(time + 10);
+
+    assert.strictEqual(stub.callCount, 1);
+
+    dispatchShortcut();
+
+    assert.strictEqual(stub.callCount, 1);
+  });
+
+  it('should stop on click', async () => {
+    const time = 100;
+    const helper = new Models.RecorderShortcutHelper.RecorderShortcutHelper(
+        time,
+    );
+    const stub = sinon.stub();
+
+    helper.handleShortcut(stub);
+    dispatchShortcut();
+
+    await waitFor(time / 2);
+    assert.strictEqual(stub.callCount, 1);
+
+    await waitFor(time);
+    assert.strictEqual(stub.callCount, 1);
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/models/RecordingPlayer_test.ts b/test/unittests/front_end/panels/recorder/models/RecordingPlayer_test.ts
new file mode 100644
index 0000000..259bf76
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/RecordingPlayer_test.ts
@@ -0,0 +1,343 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import * as RecorderHelpers from '../../../helpers/RecorderHelpers.js';
+
+// eslint-disable-next-line rulesdir/es_modules_import
+import type * as Protocol from '../../../../../../front_end/generated/protocol.js';
+
+describe('RecordingPlayer', () => {
+  let recordingPlayer: Models.RecordingPlayer.RecordingPlayer;
+
+  beforeEach(() => {
+    RecorderHelpers.installMocksForTargetManager();
+    RecorderHelpers.installMocksForRecordingPlayer();
+  });
+
+  afterEach(() => {
+    recordingPlayer.disposeForTesting();
+  });
+
+  it('should emit `Step` event before executing in every step', async () => {
+    recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+        {
+          title: 'test',
+          steps: [
+            RecorderHelpers.createCustomStep(),
+            RecorderHelpers.createCustomStep(),
+            RecorderHelpers.createCustomStep(),
+          ],
+        },
+        {
+          speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+          breakpointIndexes: new Set(),
+        },
+    );
+    const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+      resolve();
+    });
+    recordingPlayer.addEventListener(
+        Models.RecordingPlayer.Events.Step,
+        stepEventHandlerStub,
+    );
+
+    await recordingPlayer.play();
+
+    assert.isTrue(stepEventHandlerStub.getCalls().length === 3);
+  });
+
+  describe('Step by step execution', () => {
+    it('should stop execution before executing a step that has a breakpoint', async () => {
+      recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+          {
+            title: 'test',
+            steps: [
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+            ],
+          },
+          {
+            speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+            breakpointIndexes: new Set([1]),
+          },
+      );
+      const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+        resolve();
+      });
+      const stopEventPromise = new Promise<void>(resolve => {
+        recordingPlayer.addEventListener(
+            Models.RecordingPlayer.Events.Stop,
+            () => {
+              resolve();
+            },
+        );
+      });
+      recordingPlayer.addEventListener(
+          Models.RecordingPlayer.Events.Step,
+          stepEventHandlerStub,
+      );
+
+      void recordingPlayer.play();
+      await stopEventPromise;
+
+      assert.strictEqual(stepEventHandlerStub.getCalls().length, 2);
+    });
+
+    it('should `stepOver` execute only the next step after breakpoint and stop', async () => {
+      recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+          {
+            title: 'test',
+            steps: [
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+            ],
+          },
+          {
+            speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+            breakpointIndexes: new Set([1]),
+          },
+      );
+      const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+        resolve();
+      });
+      let stopEventPromise = new Promise<void>(resolve => {
+        recordingPlayer.addEventListener(
+            Models.RecordingPlayer.Events.Stop,
+            () => {
+              resolve();
+              stopEventPromise = new Promise<void>(nextResolve => {
+                recordingPlayer.addEventListener(
+                    Models.RecordingPlayer.Events.Stop,
+                    () => {
+                      nextResolve();
+                    },
+                    {once: true},
+                );
+              });
+            },
+            {once: true},
+        );
+      });
+      recordingPlayer.addEventListener(
+          Models.RecordingPlayer.Events.Step,
+          stepEventHandlerStub,
+      );
+
+      void recordingPlayer.play();
+      await stopEventPromise;
+      assert.strictEqual(stepEventHandlerStub.getCalls().length, 2);
+      recordingPlayer.stepOver();
+      await stopEventPromise;
+
+      assert.strictEqual(stepEventHandlerStub.getCalls().length, 3);
+    });
+
+    it('should `continue` execute until the next breakpoint', async () => {
+      recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+          {
+            title: 'test',
+            steps: [
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+            ],
+          },
+          {
+            speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+            breakpointIndexes: new Set([1, 3]),
+          },
+      );
+      const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+        resolve();
+      });
+      let stopEventPromise = new Promise<void>(resolve => {
+        recordingPlayer.addEventListener(
+            Models.RecordingPlayer.Events.Stop,
+            () => {
+              resolve();
+              stopEventPromise = new Promise<void>(nextResolve => {
+                recordingPlayer.addEventListener(
+                    Models.RecordingPlayer.Events.Stop,
+                    () => {
+                      nextResolve();
+                    },
+                    {once: true},
+                );
+              });
+            },
+            {once: true},
+        );
+      });
+      recordingPlayer.addEventListener(
+          Models.RecordingPlayer.Events.Step,
+          stepEventHandlerStub,
+      );
+
+      void recordingPlayer.play();
+      await stopEventPromise;
+      assert.strictEqual(stepEventHandlerStub.getCalls().length, 2);
+      recordingPlayer.continue();
+      await stopEventPromise;
+
+      assert.strictEqual(stepEventHandlerStub.getCalls().length, 4);
+    });
+
+    it('should `continue` execute until the end if there is no later breakpoints', async () => {
+      recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
+          {
+            title: 'test',
+            steps: [
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+              RecorderHelpers.createCustomStep(),
+            ],
+          },
+          {
+            speed: Models.RecordingPlayer.PlayRecordingSpeed.Normal,
+            breakpointIndexes: new Set([1]),
+          },
+      );
+      const stepEventHandlerStub = sinon.stub().callsFake(async ({data: {resolve}}) => {
+        resolve();
+      });
+      let stopEventPromise = new Promise<void>(resolve => {
+        recordingPlayer.addEventListener(
+            Models.RecordingPlayer.Events.Stop,
+            () => {
+              resolve();
+              stopEventPromise = new Promise<void>(nextResolve => {
+                recordingPlayer.addEventListener(
+                    Models.RecordingPlayer.Events.Stop,
+                    () => {
+                      nextResolve();
+                    },
+                    {once: true},
+                );
+              });
+            },
+            {once: true},
+        );
+      });
+      const doneEventPromise = new Promise<void>(resolve => {
+        recordingPlayer.addEventListener(
+            Models.RecordingPlayer.Events.Done,
+            () => {
+              resolve();
+            },
+            {once: true},
+        );
+      });
+      recordingPlayer.addEventListener(
+          Models.RecordingPlayer.Events.Step,
+          stepEventHandlerStub,
+      );
+
+      void recordingPlayer.play();
+      await stopEventPromise;
+      assert.strictEqual(stepEventHandlerStub.getCalls().length, 2);
+      recordingPlayer.continue();
+      await doneEventPromise;
+
+      assert.strictEqual(stepEventHandlerStub.getCalls().length, 5);
+    });
+  });
+
+  describe('shouldAttachToTarget', () => {
+    const {shouldAttachToTarget} = Models.RecordingPlayer;
+
+    function makeTargetInfo(
+        targetId: string,
+        type: string,
+        url: string,
+        ): Protocol.Target.TargetInfo {
+      return {
+        attached: false,
+        targetId: targetId as Protocol.Target.TargetID,
+        url,
+        canAccessOpener: false,
+        title: '',
+        type,
+      };
+    }
+
+    it('should attach to the main target of type "page"', () => {
+      assert.isTrue(
+          shouldAttachToTarget(
+              'main1',
+              makeTargetInfo('main1', 'page', 'https://example.com'),
+              ),
+      );
+    });
+
+    it('should not attach to non-main target of type "page"', () => {
+      assert.isFalse(
+          shouldAttachToTarget(
+              'main1',
+              makeTargetInfo('non-main', 'page', 'https://example.com'),
+              ),
+      );
+    });
+
+    it('should not attach to extension targets', () => {
+      assert.isFalse(
+          shouldAttachToTarget(
+              'main1',
+              makeTargetInfo('main1', 'page', 'chrome-extension://smth'),
+              ),
+      );
+    });
+
+    it('should attach to the main target if it is DevTools', () => {
+      assert.isTrue(
+          shouldAttachToTarget(
+              'main1',
+              makeTargetInfo('main1', 'page', 'devtools://smth'),
+              ),
+      );
+    });
+
+    it('should not attach to non-main DevTools target', () => {
+      assert.isFalse(
+          shouldAttachToTarget(
+              'main1',
+              makeTargetInfo('non-main', 'page', 'devtools://smth'),
+              ),
+      );
+    });
+
+    it('should attach to the main target of type "iframe"', () => {
+      assert.isTrue(
+          shouldAttachToTarget(
+              'main1',
+              makeTargetInfo('iframe1', 'iframe', 'https://example.com'),
+              ),
+      );
+    });
+
+    it('should not attach to other targts', () => {
+      assert.isFalse(
+          shouldAttachToTarget(
+              'main1',
+              makeTargetInfo(
+                  'service_worker1',
+                  'service_worker',
+                  'https://example.com',
+                  ),
+              ),
+      );
+    });
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/models/SchemaUtils_test.ts b/test/unittests/front_end/panels/recorder/models/SchemaUtils_test.ts
new file mode 100644
index 0000000..6fcb838
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/SchemaUtils_test.ts
@@ -0,0 +1,37 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+
+describe('SchemaUtils', () => {
+  it('should compare step selectors', () => {
+    const {areSelectorsEqual} = Models.SchemaUtils;
+    assert.isTrue(
+        areSelectorsEqual(
+            {type: Models.Schema.StepType.Scroll},
+            {type: Models.Schema.StepType.Scroll},
+            ),
+    );
+    assert.isFalse(
+        areSelectorsEqual(
+            {type: Models.Schema.StepType.Scroll, selectors: [['#id']]},
+            {type: Models.Schema.StepType.Scroll},
+            ),
+    );
+    assert.isTrue(
+        areSelectorsEqual(
+            {type: Models.Schema.StepType.Scroll, selectors: [['#id']]},
+            {type: Models.Schema.StepType.Scroll, selectors: [['#id']]},
+            ),
+    );
+    assert.isFalse(
+        areSelectorsEqual(
+            {type: Models.Schema.StepType.Scroll, selectors: [['#id', '#id2']]},
+            {type: Models.Schema.StepType.Scroll, selectors: [['#id']]},
+            ),
+    );
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/models/ScreenshotUtils_test.ts b/test/unittests/front_end/panels/recorder/models/ScreenshotUtils_test.ts
new file mode 100644
index 0000000..d199846
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/ScreenshotUtils_test.ts
@@ -0,0 +1,76 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+
+describe('ScreenshotUtils', () => {
+  async function generateImage(
+      width: number,
+      height: number,
+      ): Promise<Models.ScreenshotStorage.Screenshot> {
+    const img = new Image(width, height);
+    const promise = new Promise(resolve => {
+      img.>
+    });
+    img.src = `data:image/svg+xml,%3Csvg viewBox='0 0 ${width} ${
+        height}' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='50' cy='50' r='50'/%3E%3C/svg%3E`;
+    await promise;
+    const canvas = document.createElement('canvas');
+    canvas.width = width;
+    canvas.height = height;
+    const context = canvas.getContext('2d');
+    if (!context) {
+      throw new Error('Could not create context.');
+    }
+    const bitmap = await createImageBitmap(img, {
+      resizeHeight: height,
+      resizeWidth: width,
+    });
+    context.drawImage(bitmap, 0, 0);
+
+    return canvas.toDataURL('image/png') as Models.ScreenshotStorage.Screenshot;
+  }
+
+  async function getScreenshotDimensions(
+      screenshot: Models.ScreenshotStorage.Screenshot,
+      ): Promise<number[]> {
+    const tmp = new Image();
+    const promise = new Promise(resolve => {
+      tmp.>
+    });
+    tmp.src = screenshot;
+    await promise;
+    return [tmp.width, tmp.height];
+  }
+
+  it('can resize screenshots to be 160px wide and <= 240px high', async () => {
+    const {resizeScreenshot} = Models.ScreenshotUtils;
+    assert.deepStrictEqual(
+        await getScreenshotDimensions(
+            await resizeScreenshot(await generateImage(400, 800)),
+            ),
+        [160, 240],
+    );
+    assert.deepStrictEqual(
+        await getScreenshotDimensions(
+            await resizeScreenshot(await generateImage(800, 400)),
+            ),
+        [160, 80],
+    );
+    assert.deepStrictEqual(
+        await getScreenshotDimensions(
+            await resizeScreenshot(await generateImage(80, 80)),
+            ),
+        [160, 160],
+    );
+    assert.deepStrictEqual(
+        await getScreenshotDimensions(
+            await resizeScreenshot(await generateImage(80, 320)),
+            ),
+        [160, 240],
+    );
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/models/Section_test.ts b/test/unittests/front_end/panels/recorder/models/Section_test.ts
new file mode 100644
index 0000000..6d1b182
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/Section_test.ts
@@ -0,0 +1,100 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Models from '../../../../../../front_end/panels/recorder/models/models.js';
+
+describe('Section', () => {
+  describe('buildSections', () => {
+    const buildSections = Models.Section.buildSections;
+
+    function makeStep(): Models.Schema.Step {
+      return {type: Models.Schema.StepType.Scroll};
+    }
+
+    function makeNavigateStep(): Models.Schema.NavigateStep {
+      return {
+        type: Models.Schema.StepType.Navigate,
+        url: 'https://example.com',
+        assertedEvents: [
+          {
+            type: Models.Schema.AssertedEventType.Navigation,
+            url: 'https://example.com',
+            title: 'Test',
+          },
+        ],
+      };
+    }
+
+    function makeStepCausingNavigation(): Models.Schema.Step {
+      return {
+        type: Models.Schema.StepType.Scroll,
+        assertedEvents: [
+          {
+            type: Models.Schema.AssertedEventType.Navigation,
+            url: 'https://example.com',
+            title: 'Test',
+          },
+        ],
+      };
+    }
+
+    it('should build not sections for empty steps', () => {
+      assert.deepStrictEqual(buildSections([]), []);
+    });
+
+    it('should build a current page section for initial steps that do not cause navigation', () => {
+      const step1 = makeStep();
+      const step2 = makeStep();
+      assert.deepStrictEqual(buildSections([step1, step2]), [
+        {title: 'Current page', url: '', steps: [step1, step2]},
+      ]);
+    });
+
+    it('should build a current page section for initial steps that cause navigation', () => {
+      {
+        const step1 = makeNavigateStep();
+        const step2 = makeStep();
+        assert.deepStrictEqual(buildSections([step1, step2]), [
+          {
+            title: 'Test',
+            url: 'https://example.com',
+            steps: [step2],
+            causingStep: step1,
+          },
+        ]);
+      }
+
+      {const step1 = makeStepCausingNavigation(); const step2 = makeStep(); assert.deepStrictEqual(
+          buildSections([step1, step2]),
+          [
+            {title: 'Current page', url: '', steps: [step1]},
+            {title: 'Test', url: 'https://example.com', steps: [step2]},
+          ]);}
+    });
+
+    it('should generate multiple sections', () => {
+      const step1 = makeStep();
+      const step2 = makeNavigateStep();
+      const step3 = makeStep();
+      const step4 = makeStepCausingNavigation();
+      const step5 = makeStep();
+
+      assert.deepStrictEqual(
+          buildSections([step1, step2, step3, step4, step5]),
+          [
+            {title: 'Current page', url: '', steps: [step1, step2]},
+            {
+              title: 'Test',
+              url: 'https://example.com',
+              steps: [step3, step4],
+              causingStep: step2,
+            },
+            {title: 'Test', url: 'https://example.com', steps: [step5]},
+          ],
+      );
+    });
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/models/recording-storage_test.ts b/test/unittests/front_end/panels/recorder/models/recording-storage_test.ts
new file mode 100644
index 0000000..ece4452
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/recording-storage_test.ts
@@ -0,0 +1,67 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Recorder from '../../../../../../front_end/panels/recorder/models/models.js';
+import {
+  describeWithEnvironment,
+} from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+describeWithEnvironment('RecordingStorage', () => {
+  beforeEach(() => {
+    Recorder.RecordingStorage.RecordingStorage.instance().clearForTest();
+  });
+
+  after(() => {
+    Recorder.RecordingStorage.RecordingStorage.instance().clearForTest();
+  });
+
+  class MockIdGenerator {
+    #id = 1;
+    next() {
+      const result = `recording_${this.#id}`;
+      this.#id++;
+      return result;
+    }
+  }
+
+  it('should create and retrieve recordings', async () => {
+    const storage = Recorder.RecordingStorage.RecordingStorage.instance();
+    storage.setIdGeneratorForTest(new MockIdGenerator());
+    const flow1 = {title: 'Test1', steps: []};
+    const flow2 = {title: 'Test2', steps: []};
+    const flow3 = {title: 'Test3', steps: []};
+    assert.deepEqual(await storage.saveRecording(flow1), {
+      storageName: 'recording_1',
+      flow: flow1,
+    });
+    assert.deepEqual(await storage.saveRecording(flow2), {
+      storageName: 'recording_2',
+      flow: flow2,
+    });
+    assert.deepEqual(await storage.getRecordings(), [
+      {storageName: 'recording_1', flow: flow1},
+      {storageName: 'recording_2', flow: flow2},
+    ]);
+    assert.deepEqual(await storage.getRecording('recording_2'), {
+      storageName: 'recording_2',
+      flow: flow2,
+    });
+    assert.deepEqual(await storage.getRecording('recording_3'), undefined);
+    assert.deepEqual(await storage.updateRecording('recording_2', flow3), {
+      storageName: 'recording_2',
+      flow: flow3,
+    });
+    assert.deepEqual(await storage.getRecording('recording_2'), {
+      storageName: 'recording_2',
+      flow: flow3,
+    });
+
+    await storage.deleteRecording('recording_2');
+    assert.deepEqual(await storage.getRecordings(), [
+      {storageName: 'recording_1', flow: flow1},
+    ]);
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/models/screenshot-storage_test.ts b/test/unittests/front_end/panels/recorder/models/screenshot-storage_test.ts
new file mode 100644
index 0000000..3423672
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/models/screenshot-storage_test.ts
@@ -0,0 +1,161 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Recorder from '../../../../../../front_end/panels/recorder/models/models.js';
+import * as Common from '../../../../../../front_end/core/common/common.js';
+
+import {
+  describeWithEnvironment,
+} from '../../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+
+let instance: Recorder.ScreenshotStorage.ScreenshotStorage;
+
+describeWithEnvironment('ScreenshotStorage', () => {
+  beforeEach(() => {
+    instance = Recorder.ScreenshotStorage.ScreenshotStorage.instance();
+    instance.clear();
+  });
+
+  it('should return null if no screenshot has been stored for the given index', () => {
+    const imageData = instance.getScreenshotForSection('recording-1', 1);
+    assert.isNull(imageData);
+  });
+
+  it('should return the stored image data when a screenshot has been stored for the given index', () => {
+    const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+    instance.storeScreenshotForSection('recording-1', 1, imageData);
+    const retrievedImageData = instance.getScreenshotForSection(
+        'recording-1',
+        1,
+    );
+    assert.strictEqual(retrievedImageData, imageData);
+  });
+
+  it('should load previous screenshots from settings', () => {
+    const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+    const setting = Common.Settings.Settings.instance().createSetting<Recorder.ScreenshotStorage.ScreenshotMetaData[]>(
+        'recorder_screenshots', []);
+    setting.set([{recordingName: 'recording-1', index: 1, data: imageData}]);
+
+    const screenshotStorage = Recorder.ScreenshotStorage.ScreenshotStorage.instance({forceNew: true});
+    const retrievedImageData = screenshotStorage.getScreenshotForSection(
+        'recording-1',
+        1,
+    );
+    assert.strictEqual(retrievedImageData, imageData);
+  });
+
+  it('should sync screenshots to settings', () => {
+    const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+    instance.storeScreenshotForSection('recording-1', 1, imageData);
+
+    const setting = Common.Settings.Settings.instance().createSetting<Recorder.ScreenshotStorage.ScreenshotMetaData[]>(
+        'recorder_screenshots', []);
+    const value = setting.get();
+    assert.strictEqual(value.length, 1);
+    assert.strictEqual(value[0].index, 1);
+    assert.strictEqual(value[0].data, imageData);
+  });
+
+  it('should limit the amount of stored screenshots', () => {
+    const screenshotStorage = Recorder.ScreenshotStorage.ScreenshotStorage.instance({
+      forceNew: true,
+      maxStorageSize: 2,
+    });
+
+    screenshotStorage.storeScreenshotForSection(
+        'recording-1',
+        1,
+        '1' as Recorder.ScreenshotStorage.Screenshot,
+    );
+    screenshotStorage.storeScreenshotForSection(
+        'recording-1',
+        2,
+        '2' as Recorder.ScreenshotStorage.Screenshot,
+    );
+    screenshotStorage.storeScreenshotForSection(
+        'recording-1',
+        3,
+        '3' as Recorder.ScreenshotStorage.Screenshot,
+    );
+
+    const imageData1 = screenshotStorage.getScreenshotForSection(
+        'recording-1',
+        1,
+    );
+    const imageData2 = screenshotStorage.getScreenshotForSection(
+        'recording-1',
+        2,
+    );
+    const imageData3 = screenshotStorage.getScreenshotForSection(
+        'recording-1',
+        3,
+    );
+
+    assert.isNull(imageData1);
+    assert.isNotNull(imageData2);
+    assert.isNotNull(imageData3);
+  });
+
+  it('should drop the oldest screenshots first', () => {
+    const screenshotStorage = Recorder.ScreenshotStorage.ScreenshotStorage.instance({
+      forceNew: true,
+      maxStorageSize: 2,
+    });
+
+    screenshotStorage.storeScreenshotForSection(
+        'recording-1',
+        1,
+        '1' as Recorder.ScreenshotStorage.Screenshot,
+    );
+    screenshotStorage.storeScreenshotForSection(
+        'recording-1',
+        2,
+        '2' as Recorder.ScreenshotStorage.Screenshot,
+    );
+    screenshotStorage.getScreenshotForSection('recording-1', 1);
+    screenshotStorage.storeScreenshotForSection(
+        'recording-1',
+        3,
+        '3' as Recorder.ScreenshotStorage.Screenshot,
+    );
+
+    const imageData1 = screenshotStorage.getScreenshotForSection(
+        'recording-1',
+        1,
+    );
+    const imageData2 = screenshotStorage.getScreenshotForSection(
+        'recording-1',
+        2,
+    );
+    const imageData3 = screenshotStorage.getScreenshotForSection(
+        'recording-1',
+        3,
+    );
+
+    assert.isNotNull(imageData1);
+    assert.isNull(imageData2);
+    assert.isNotNull(imageData3);
+  });
+
+  it('should namespace the screenshots by recording name', () => {
+    const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+
+    instance.storeScreenshotForSection('recording-1', 1, imageData);
+    const storedImageData = instance.getScreenshotForSection('recording-2', 1);
+
+    assert.isNull(storedImageData);
+  });
+
+  it('should delete screenshots by recording name', () => {
+    const imageData = 'data:image/jpeg;base64,...' as Recorder.ScreenshotStorage.Screenshot;
+
+    instance.storeScreenshotForSection('recording-1', 1, imageData);
+    const storedImageData = instance.getScreenshotForSection('recording-2', 1);
+
+    assert.isNull(storedImageData);
+  });
+});
diff --git a/test/unittests/front_end/panels/recorder/util/BUILD.gn b/test/unittests/front_end/panels/recorder/util/BUILD.gn
new file mode 100644
index 0000000..f7cb7a2
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/util/BUILD.gn
@@ -0,0 +1,13 @@
+# Copyright 2023 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import(
+    "../../../../../../third_party/typescript/typescript.gni")
+
+ts_library("util") {
+  testonly = true
+  sources = [ "SharedObject_test.ts" ]
+
+  deps = [ "../../../../../../front_end/panels/recorder/util:bundle" ]
+}
diff --git a/test/unittests/front_end/panels/recorder/util/SharedObject_test.ts b/test/unittests/front_end/panels/recorder/util/SharedObject_test.ts
new file mode 100644
index 0000000..7869898
--- /dev/null
+++ b/test/unittests/front_end/panels/recorder/util/SharedObject_test.ts
@@ -0,0 +1,90 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as Util from '../../../../../../front_end/panels/recorder/util/util.js';
+
+describe('SharedObject', () => {
+  it('should work', async () => {
+    // The test object
+    const testObject = {value: false};
+
+    const object = new Util.SharedObject.SharedObject(
+        () => {
+          testObject.value = true;
+          return {...testObject};
+        },
+        object => {
+          object.value = false;
+        });
+
+    // No one acquired.
+    assert.isFalse(testObject.value);
+
+    // First acquire.
+    const [object1, release1] = await object.acquire();
+    // Should be created.
+    assert.notStrictEqual(object1, testObject);
+    // Acquired actually occured.
+    assert.isTrue(testObject.value);
+
+    // The second object should be the same.
+    const [object2, release2] = await object.acquire();
+    // Should equal the first acquired object.
+    assert.strictEqual(object2, object1);
+    // Should still be true.
+    assert.isTrue(object1.value);
+
+    // First release (can be in any order).
+    await release1();
+    // Should still be true.
+    assert.isTrue(object1.value);
+
+    // Second release.
+    await release2();
+    assert.isFalse(object1.value);
+  });
+  it('should work with run', async () => {
+    // The test object
+    const testObject = {value: false};
+
+    const object = new Util.SharedObject.SharedObject(
+        () => {
+          testObject.value = true;
+          return {...testObject};
+        },
+        object => {
+          object.value = false;
+        });
+
+    // No one acquired.
+    assert.isFalse(testObject.value);
+
+    let finalObject: {value: boolean}|undefined;
+    const promises = [];
+
+    // First acquire.
+    promises.push(object.run(async object1 => {
+      // Should be created.
+      assert.notStrictEqual(object1, testObject);
+      // Acquired actually occured.
+      assert.isTrue(testObject.value);
+
+      promises.push(object.run(async object2 => {
+        // Should equal the first acquired object.
+        assert.strictEqual(object2, object1);
+        // Should still be true.
+        assert.isTrue(object1.value);
+
+        finalObject = object1;
+      }));
+    }));
+
+    await Promise.all(promises);
+
+    assert.isDefined(finalObject);
+    assert.isFalse(finalObject?.value);
+  });
+});