` from the corresponding test case.
+
+## Observable Outputs Harness
+
+Tests run with **Playwright locators only** — no `page.evaluate`. When a test
+must read a live value back from the running editor or a mock service, the story
+publishes it through the **Observable Outputs Harness**: stable, hidden DOM
+elements addressed by `data-test` ids, which Playwright reads with a locator.
+
+- `installOutputsHarness(editor, element, args)` (`src/runtime/outputs.ts`)
+ wires the requested outputs into `` elements inside the
+ outputs container (`OUTPUTS_CONTAINER_CLASS`).
+- The available outputs (`OUTPUT_TEST_IDS` in
+ `@coremedia/ckeditor5-itest-constants`) are:
+ - `editor-data` — the current `editor.getData()`.
+ - `data-view` — the rendered `richtext:toView` data view (re-set from the
+ story's original loaded `data`, since `getData()` strips editing-only
+ augmentations such as ``).
+ - `last-opened-entities` — the awaited result of the work-area service's
+ `getLastOpenedEntities()`.
+ - `is-droppable-state` / `is-droppable-in-link-balloon` — drag-and-drop
+ droppability signals.
+- On the Playwright side, `tests/playwright/test/locators/outputs.ts` exposes
+ matching locator readers (`editorData`, `dataView`, `lastOpenedEntities`,
+ `isDroppableState`, `isDroppableInLinkBalloon`).
+
+Stories can also bake in a **prepared clipboard** payload: when a scenario's
+`clipboard` arg is set, the runtime writes that `text/html` (or other) item to
+the browser clipboard while mounting (`src/setup/clipboard.ts`). This lets the
+clipboard-driven `FontMapper` test paste a fully prepared Word document and
+assert through the `editor-data` output — so it, too, runs with **locators
+only, no `page.evaluate`** (the browser context grants `clipboard-write` via the
+Playwright config).
+
+## How Playwright Targets Storybook
+
+Playwright no longer talks to the `app`. Instead:
+
+1. `tests/playwright/playwright.config.ts` declares a single `webServer` that
+ runs `pnpm run webserver:storybook` (which starts this package's Storybook
+ dev server) and waits for `http://localhost:6006`.
+2. `tests/playwright/test/storybook/environment.ts` builds the story preview URL
+ (`storyUrl(storyId)`).
+3. `tests/playwright/test/storybook/mountStory.ts#openStory` navigates to that
+ iframe URL and waits for the scenario-ready signal
+ (`data-editor-ready="true"`).
+
+Run the tests as usual:
+
+```bash
+# Full suite (starts Storybook automatically)
+pnpm --filter "@coremedia/ckeditor5-playwright-itest" run ui-test
+
+# A single test file (one Playwright project per *.test.ts)
+pnpm --filter "@coremedia/ckeditor5-playwright-itest" run ui-test --project=HelloEditor.test.ts
+```
+
+Useful environment variables: `STORYBOOK_PORT`/`STORYBOOK_HOST`,
+`PLAYWRIGHT_RETRIES`, `PLAYWRIGHT_TIMEOUT_FACTOR`.
+
+## CKEditor License (`.env`)
+
+The editor factories read `CKEDITOR_LICENSE_KEY` (use `GPL` for the GNU GPL).
+`.storybook/main.ts` resolves it from the first available `.env`:
+
+1. `tests/storybook/.env` (local to this package),
+2. `app/.env`,
+3. the workspace-root `.env`.
+
+In CI the value comes from the `CKEDITOR_LICENSE` secret (see the deployment
+workflow and `build.yml`).
+
+## GitHub Pages Deployment
+
+The workflow `.github/workflows/deploy-storybook.yml` builds and publishes this
+Storybook on every push to `main` (and on manual dispatch).
+
+To **coexist with the existing GitHub Pages content** (the API documentation is
+served from the Pages root at `…/docs/api/`), the static Storybook is published
+into a dedicated `storybook/` subfolder of the `gh-pages` branch using
+`peaceiris/actions-gh-pages` with `keep_files: true`. Sibling content is left
+untouched.
+
+- **Live URL:**
+- **Base path:** none required — the static build uses **relative** asset paths,
+ so it works unchanged under the `/storybook/` subpath.
+
+[Storybook]: "Storybook: Frontend workshop for UI development"
+[playwright]: <../playwright> "Playwright integration tests"
diff --git a/tests/storybook/eslint.config.mjs b/tests/storybook/eslint.config.mjs
new file mode 100644
index 0000000000..d46f8fd128
--- /dev/null
+++ b/tests/storybook/eslint.config.mjs
@@ -0,0 +1,8 @@
+import { createEslintConfig } from "@coremedia/studio-client.eslint-config";
+
+export default [
+ {
+ ignores: ["build/**", "storybook-static/**"],
+ },
+ ...createEslintConfig(),
+];
diff --git a/tests/storybook/package.json b/tests/storybook/package.json
new file mode 100644
index 0000000000..5c9e9add4b
--- /dev/null
+++ b/tests/storybook/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@coremedia/ckeditor5-storybook-itest",
+ "version": "28.0.0",
+ "author": {
+ "name": "CoreMedia GmbH",
+ "email": "info@coremedia.com",
+ "url": "https://coremedia.com/",
+ "avatar": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2OTguNjY3IiBoZWlnaHQ9IjY5OC42NjciIHZlcnNpb249IjEuMCIgdmlld0JveD0iMCAwIDUyNCA1MjQiPjxwYXRoIGQ9Ik0yODIuNS42QzI0NSA0LjUgMjE1LjEgMTIuOSAxODUgMjcuOSAxMDYuNCA2Ny4yIDUzLjUgMTQyLjEgNDIuNCAyMjkuNWMtMi4xIDE3LjItMi4yIDQ4IDAgNjQuMUM0OCAzMzYuMiA2MS4zIDM3Mi4yIDg0IDQwNi4zYzYxLjkgOTMuMSAxNzUuNCAxMzYuNCAyODUgMTA4LjcgMTkuMi00LjggMzMuNy0xMC4zIDUzLjItMjAuMSAyMS0xMC41IDQyLjUtMjUuMyA1OC4zLTQwLjJsOC03LjYtMjEuNS0yMS42Yy0yMy42LTIzLjgtMjcuMS0yNi43LTM4LjYtMzIuNC0yMi40LTExLjItNDQuNy0xMS42LTcxLjctMS41LTE4LjYgNy0zMC40IDkuNC00OC43IDEwLjEtMTMgLjQtMTcuNS4yLTI3LjgtMS42LTMwLjUtNS4yLTU1LTE3LjgtNzYuOC0zOS41LTMzLjYtMzMuNi00Ny44LTgwLjQtMzguNC0xMjYuNCAxMC4yLTUwLjUgNDYuMi05MC41IDk1LjItMTA2LjEgMzEuNy0xMC4xIDY0LjItOC44IDk2LjYgMy45IDEyLjggNC45IDIyLjcgNyAzNC4xIDcgMTYuNCAwIDMxLjItNC4yIDQ1LjEtMTIuNyA1LjUtMy40IDEzLjYtMTAuNyAzMC40LTI3LjNsMjIuOC0yMi42LTkuMy04LjRjLTM5LjgtMzUuNC04NS42LTU3LTEzOC40LTY1LjEtMTIuNS0yLTQ4LjctMy4zLTU5LTIuM3oiLz48cGF0aCBkPSJNMjk2IDIxOC4xYy0yOC4zIDQuOC00NC4zIDM1LjQtMzIuMSA2MS40IDcuOSAxNyAyNy4yIDI3LjQgNDUuNCAyNC41IDI4LjktNC42IDQ1LjEtMzUuMiAzMi43LTYxLjctOC0xNy4yLTI3LjMtMjcuMy00Ni0yNC4yeiIvPjwvc3ZnPg=="
+ },
+ "license": "Apache-2.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "@coremedia-internal/ckeditor5-coremedia-example-data": "workspace:^1.0.0-SNAPSHOT",
+ "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock": "workspace:^1.0.0-SNAPSHOT",
+ "@coremedia/ckeditor5-bbcode": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-coremedia-blocklist": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-coremedia-content-clipboard": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-coremedia-differencing": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-coremedia-images": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-coremedia-link": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-coremedia-richtext": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-coremedia-studio-essentials": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-coremedia-studio-integration": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-data-facade": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-dataprocessor-support": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-dialog-visibility": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-dom-converter": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-font-mapper": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-itest-constants": "workspace:^27.0.0",
+ "@coremedia/ckeditor5-link-common": "workspace:^27.0.0",
+ "ckeditor5": "catalog:"
+ },
+ "devDependencies": {
+ "@coremedia/studio-client.build-config": "catalog:",
+ "@coremedia/studio-client.eslint-config": "catalog:",
+ "@storybook/addon-essentials": "catalog:",
+ "@storybook/addon-interactions": "catalog:",
+ "@storybook/addon-webpack5-compiler-swc": "catalog:",
+ "@storybook/html": "catalog:",
+ "@storybook/html-webpack5": "catalog:",
+ "@storybook/test": "catalog:",
+ "@types/node": "catalog:",
+ "dotenv": "catalog:",
+ "eslint": "catalog:",
+ "rimraf": "catalog:",
+ "storybook": "catalog:",
+ "typescript": "catalog:",
+ "webpack": "catalog:"
+ },
+ "scripts": {
+ "clean": "rimraf ./build",
+ "typecheck": "tsc --noEmit",
+ "storybook": "storybook dev --ci --port 6006",
+ "build-storybook": "storybook build --output-dir ./build/storybook",
+ "lint": "eslint",
+ "test-storybook": "storybook test --ci"
+ }
+}
diff --git a/tests/storybook/src/editors/bbCode.ts b/tests/storybook/src/editors/bbCode.ts
new file mode 100644
index 0000000000..8640d64cc8
--- /dev/null
+++ b/tests/storybook/src/editors/bbCode.ts
@@ -0,0 +1,310 @@
+import { BBCode } from "@coremedia/ckeditor5-bbcode";
+import { DataFacade } from "@coremedia/ckeditor5-data-facade";
+import { Blocklist } from "@coremedia/ckeditor5-coremedia-blocklist";
+import { MockBlocklistService } from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import {
+ Autoformat,
+ AutoImage,
+ AutoLink,
+ Autosave,
+ Base64UploadAdapter,
+ BlockQuote,
+ Bold,
+ ClassicEditor,
+ CodeBlock,
+ Essentials,
+ FontColor,
+ FontSize,
+ Heading,
+ ImageBlockEditing,
+ ImageInline,
+ ImageInsert,
+ ImageInsertViaUrl,
+ ImageToolbar,
+ Indent,
+ Italic,
+ Link,
+ LinkImage,
+ List,
+ ListProperties,
+ Paragraph,
+ PasteFromOffice,
+ RemoveFormat,
+ SourceEditing,
+ Strikethrough,
+ Underline,
+} from "ckeditor5";
+import type { ScenarioArgs } from "../runtime/scenario";
+import { licenseKey, licenseKeyErrorMessage } from "./license";
+
+/**
+ * Creates the BBCode editor used by the migrated Playwright scenarios. Ported
+ * from `app/src/editors/bbCode.ts`, with the application's preview wiring
+ * removed; scenario configuration is provided through {@link ScenarioArgs}
+ * instead.
+ *
+ * @param sourceElement - host element the editor is mounted into
+ * @param args - resolved scenario args
+ */
+export const createBBCodeEditor = (sourceElement: HTMLElement, args: ScenarioArgs): Promise => {
+ const { uiLanguage } = args;
+
+ try {
+ return ClassicEditor.create(sourceElement, {
+ licenseKey,
+ placeholder: "Type your text here...",
+ plugins: [
+ AutoImage,
+ AutoLink,
+ Autoformat,
+ Autosave,
+ // Base64: Not recommended in production use.
+ Base64UploadAdapter,
+ BBCode,
+ BlockQuote,
+ Blocklist,
+ Bold,
+ CodeBlock,
+ DataFacade,
+ List,
+ ListProperties,
+ Essentials,
+ FontColor,
+ FontSize,
+ Heading,
+ // ImageBlockEditing: Required by LinkImage; but images in BBCode are always inline.
+ ImageBlockEditing,
+ ImageInline,
+ ImageInsert,
+ ImageInsertViaUrl,
+ ImageToolbar,
+ Indent,
+ Italic,
+ Link,
+ LinkImage,
+ MockBlocklistService,
+ Paragraph,
+ PasteFromOffice,
+ RemoveFormat,
+ SourceEditing,
+ Strikethrough,
+ Underline,
+ ],
+ toolbar: [
+ "undo",
+ "redo",
+ "|",
+ "heading",
+ "|",
+ "bold",
+ "italic",
+ "underline",
+ "strikethrough",
+ "fontSize",
+ "fontColor",
+ "removeFormat",
+ "|",
+ "link",
+ "imageInsert",
+ "|",
+ "blockQuote",
+ "codeBlock",
+ "|",
+ "numberedList",
+ "bulletedList",
+ "outdent",
+ "indent",
+ "|",
+ "blocklist",
+ "|",
+ "sourceEditing",
+ ],
+ autosave: {
+ waitingTime: 1000,
+ },
+ codeBlock: {
+ languages: [
+ {
+ language: "plaintext",
+ label: "Plain text",
+ },
+ {
+ language: "bbcode",
+ label: "BBCode",
+ },
+ {
+ language: "c",
+ label: "C",
+ },
+ {
+ language: "cs",
+ label: "C#",
+ },
+ {
+ language: "cpp",
+ label: "C++",
+ },
+ {
+ language: "css",
+ label: "CSS",
+ },
+ {
+ language: "diff",
+ label: "Diff",
+ },
+ {
+ language: "html",
+ label: "HTML",
+ },
+ {
+ language: "java",
+ label: "Java",
+ },
+ {
+ language: "javascript",
+ label: "JavaScript",
+ },
+ {
+ language: "php",
+ label: "PHP",
+ },
+ {
+ language: "python",
+ label: "Python",
+ },
+ {
+ language: "ruby",
+ label: "Ruby",
+ },
+ {
+ language: "typescript",
+ label: "TypeScript",
+ },
+ {
+ language: "xml",
+ label: "XML",
+ },
+ ],
+ },
+ dataFacade: {
+ save(): Promise {
+ return Promise.resolve();
+ },
+ },
+ fontColor: {
+ colors: [
+ {
+ color: "hsl(0,0%,0%)",
+ label: "Black",
+ },
+ {
+ color: "#ff0000",
+ label: "Red",
+ },
+ {
+ color: "rgb(255,255,0)",
+ label: "Yellow",
+ },
+ {
+ color: "#00ff00",
+ label: "Green",
+ },
+ {
+ color: "#00ffff",
+ label: "Cyan",
+ },
+ {
+ color: "#0000ff",
+ label: "Blue",
+ },
+ {
+ color: "fuchsia",
+ label: "Fuchsia",
+ },
+ {
+ color: "#ffffff",
+ label: "White",
+ hasBorder: true,
+ },
+ {
+ color: "rgba(0,0,0,0.63)",
+ label: "Darken",
+ },
+ ],
+ colorPicker: {
+ format: "hex",
+ },
+ },
+ heading: {
+ options: [
+ {
+ model: "paragraph",
+ title: "Paragraph",
+ class: "ck-heading_paragraph",
+ },
+ {
+ model: "heading1",
+ view: "h1",
+ title: "Heading 1",
+ class: "ck-heading_heading1",
+ },
+ {
+ model: "heading2",
+ view: "h2",
+ title: "Heading 2",
+ class: "ck-heading_heading2",
+ },
+ {
+ model: "heading3",
+ view: "h3",
+ title: "Heading 3",
+ class: "ck-heading_heading3",
+ },
+ {
+ model: "heading4",
+ view: "h4",
+ title: "Heading 4",
+ class: "ck-heading_heading4",
+ },
+ {
+ model: "heading5",
+ view: "h5",
+ title: "Heading 5",
+ class: "ck-heading_heading5",
+ },
+ {
+ model: "heading6",
+ view: "h6",
+ title: "Heading 6",
+ class: "ck-heading_heading6",
+ },
+ ],
+ },
+ image: {
+ toolbar: ["imageTextAlternative"],
+ upload: {
+ types: ["jpeg", "png", "gif", "bmp", "webp", "tiff", "avif", "svg"],
+ },
+ },
+ language: {
+ ui: uiLanguage,
+ content: "en",
+ },
+ list: {
+ properties: {
+ startIndex: false,
+ styles: {
+ useAttribute: true,
+ },
+ reversed: false,
+ },
+ },
+ link: {
+ defaultProtocol: "https://",
+ },
+ });
+ } catch (e: unknown) {
+ console.error("Caught error when creating Editor.", e);
+ throw Error(licenseKeyErrorMessage);
+ }
+};
diff --git a/tests/storybook/src/editors/index.ts b/tests/storybook/src/editors/index.ts
new file mode 100644
index 0000000000..b00d51836d
--- /dev/null
+++ b/tests/storybook/src/editors/index.ts
@@ -0,0 +1,41 @@
+import type { ClassicEditor } from "ckeditor5";
+import type { ScenarioArgs, ScenarioDataType } from "../runtime/scenario";
+import type { ScenarioInitializer } from "../runtime/mountStory";
+import { applyScenario } from "../setup/applyScenario";
+import { createRichTextEditor } from "./richtext";
+import { createBBCodeEditor } from "./bbCode";
+
+/**
+ * Factory creating a concrete editor instance for a scenario data type.
+ */
+export type EditorFactory = (host: HTMLElement, args: ScenarioArgs) => Promise;
+
+/**
+ * Registry of editor factories per data type, ported from the former
+ * application `ckEditorInstanceFactories`.
+ */
+export const editorFactories: Record = {
+ richtext: createRichTextEditor,
+ bbcode: createBBCodeEditor,
+};
+
+/**
+ * Builds the {@link ScenarioInitializer} used by `mountScenario`: it creates the
+ * editor matching `args.dataType` and then applies the declarative scenario
+ * setup (mock contents, read-only state, initial data).
+ *
+ * This is the bridge between the editor factories and the in-page setup
+ * utilities that replaced the former Playwright wrapper-based setup.
+ */
+export const createEditorScenario: ScenarioInitializer = async (host, args) => {
+ const factory = editorFactories[args.dataType];
+ const editor = await factory(host, args);
+ const applyCleanup = await applyScenario(editor, args);
+
+ const cleanup = (): void => {
+ applyCleanup();
+ void editor.destroy().catch((error: unknown) => console.error("Failed to destroy editor scenario", error));
+ };
+
+ return { editor, cleanup };
+};
diff --git a/tests/storybook/src/editors/license.ts b/tests/storybook/src/editors/license.ts
new file mode 100644
index 0000000000..b5d359a141
--- /dev/null
+++ b/tests/storybook/src/editors/license.ts
@@ -0,0 +1,17 @@
+/**
+ * License key for the CKEditor 5 instances used in stories. The value is
+ * injected at build time via the Storybook webpack `DefinePlugin`
+ * (see `.storybook/main.ts`), mirroring the former application build
+ * (`app/webpack.config.js`). Use `GPL` (or a valid commercial key) through the
+ * `CKEDITOR_LICENSE_KEY` environment variable / repository `.env` file.
+ */
+declare const CKEDITOR_LICENSE_KEY: string | undefined;
+
+export const licenseKeyErrorMessage =
+ "Please provide a valid license key for your CKEditor 5 instance. Create a .env file in the workspace root and set the CKEDITOR_LICENSE_KEY variable. Use 'GPL' if you want to use the GNU General Public License.";
+
+/**
+ * Resolved CKEditor 5 license key, replaced during the Storybook build.
+ */
+export const licenseKey: string | undefined =
+ typeof CKEDITOR_LICENSE_KEY === "undefined" ? undefined : CKEDITOR_LICENSE_KEY;
diff --git a/tests/storybook/src/editors/richtext.ts b/tests/storybook/src/editors/richtext.ts
new file mode 100644
index 0000000000..e2223c1fd0
--- /dev/null
+++ b/tests/storybook/src/editors/richtext.ts
@@ -0,0 +1,425 @@
+import { DialogVisibility } from "@coremedia/ckeditor5-dialog-visibility";
+import {
+ ContentLinks,
+ COREMEDIA_CONTEXT_KEY,
+ COREMEDIA_LINK_CONFIG_KEY,
+ LinkTarget,
+} from "@coremedia/ckeditor5-coremedia-link";
+import { ContentClipboard, PasteContentPlugin } from "@coremedia/ckeditor5-coremedia-content-clipboard";
+import { ContentImagePlugin } from "@coremedia/ckeditor5-coremedia-images";
+import { FontMapper as CoreMediaFontMapper } from "@coremedia/ckeditor5-font-mapper";
+import {
+ COREMEDIA_MOCK_CONTENT_PLUGIN,
+ MockInputExamplePlugin,
+ MockStudioIntegration,
+} from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import {
+ COREMEDIA_RICHTEXT_CONFIG_KEY,
+ COREMEDIA_RICHTEXT_SUPPORT_CONFIG_KEY,
+ CoreMediaStudioEssentials,
+ Strictness,
+} from "@coremedia/ckeditor5-coremedia-studio-essentials";
+import type { PluginConstructor } from "ckeditor5";
+import {
+ Alignment,
+ Autoformat,
+ AutoLink,
+ Autosave,
+ BlockQuote,
+ Bold,
+ ClassicEditor,
+ Code,
+ CodeBlock,
+ Essentials,
+ FindAndReplace,
+ Heading,
+ Highlight,
+ IconObjectInline,
+ IconObjectLeft,
+ IconObjectRight,
+ IconObjectSizeFull,
+ ImageBlockEditing,
+ ImageInline,
+ ImageStyle,
+ ImageTextAlternative,
+ ImageToolbar,
+ Indent,
+ Italic,
+ Link,
+ LinkImage,
+ List,
+ Paragraph,
+ PasteFromOffice,
+ RemoveFormat,
+ SourceEditing,
+ Strikethrough,
+ Subscript,
+ Superscript,
+ Table,
+ TableToolbar,
+ Underline,
+} from "ckeditor5";
+import type { RuleConfig } from "@coremedia/ckeditor5-dom-converter";
+import type {
+ LatestCoreMediaRichTextConfig,
+ V10CoreMediaRichTextConfig,
+} from "@coremedia/ckeditor5-coremedia-richtext";
+import {
+ replaceByElementAndClassBackAndForth,
+ replaceElementByElementAndClass,
+ stripFixedAttributes,
+} from "@coremedia/ckeditor5-coremedia-richtext";
+import "ckeditor5/ckeditor5.css";
+import type { FilterRuleSetConfiguration } from "@coremedia/ckeditor5-dataprocessor-support";
+import type { LinkAttributesConfig } from "@coremedia/ckeditor5-link-common";
+import { LinkAttributes } from "@coremedia/ckeditor5-link-common";
+import { linkBalloonScenario } from "@coremedia/ckeditor5-itest-constants";
+import { Differencing } from "@coremedia/ckeditor5-coremedia-differencing";
+import { Blocklist } from "@coremedia/ckeditor5-coremedia-blocklist";
+import { DataFacade } from "@coremedia/ckeditor5-data-facade";
+import type { ScenarioArgs } from "../runtime/scenario";
+import { licenseKey, licenseKeyErrorMessage } from "./license";
+
+const withinTextIcon = IconObjectInline;
+const alignLeftIcon = IconObjectLeft;
+const alignRightIcon = IconObjectRight;
+const pageDefaultIcon = IconObjectSizeFull;
+
+const imagePlugins: PluginConstructor[] = [
+ ContentImagePlugin,
+ ImageInline,
+ ImageBlockEditing,
+ ImageStyle,
+ ImageToolbar,
+ ImageTextAlternative,
+];
+
+/**
+ * Apply custom mapping rules.
+ */
+const richTextRuleConfigurations: RuleConfig[] = [
+ // Highlight plugin support.
+ replaceElementByElementAndClass({
+ viewLocalName: "mark",
+ dataLocalName: "span",
+ dataReservedClass: "mark",
+ }),
+ stripFixedAttributes(),
+];
+
+/**
+ * v10 compatible configuration.
+ */
+const v10RichTextRuleConfigurations: FilterRuleSetConfiguration = {
+ elements: {
+ // Highlight Plugin Support
+ mark: replaceByElementAndClassBackAndForth("mark", "span", "mark"),
+ },
+};
+
+const linkAttributesConfig: LinkAttributesConfig = {
+ attributes: [
+ {
+ view: "title",
+ model: "linkTitle",
+ },
+ {
+ view: "data-xlink-actuate",
+ model: "linkActuate",
+ },
+ ],
+};
+
+const getRichTextConfig = (
+ richTextCompatibility: string | true,
+): Partial | V10CoreMediaRichTextConfig => {
+ // Use v10 for first data-processor architecture, for example.
+ if (richTextCompatibility === "v10") {
+ return {
+ strictness: Strictness.STRICT,
+ compatibility: "v10",
+ rules: v10RichTextRuleConfigurations,
+ };
+ }
+ return {
+ strictness: Strictness.STRICT,
+ compatibility: "latest",
+ rules: richTextRuleConfigurations,
+ };
+};
+
+/**
+ * Creates the CoreMedia RichText editor used by the migrated Playwright
+ * scenarios. Ported from `app/src/editors/richtext.ts`, with the application's
+ * preview/inspector/hash-parameter wiring removed; scenario configuration is
+ * provided through {@link ScenarioArgs} instead.
+ *
+ * @param sourceElement - host element the editor is mounted into
+ * @param args - resolved scenario args
+ */
+export const createRichTextEditor = async (sourceElement: HTMLElement, args: ScenarioArgs): Promise => {
+ const { uiLanguage } = args;
+ const contextUriPath = `content/123`;
+
+ try {
+ const editor = await ClassicEditor.create(sourceElement, {
+ licenseKey,
+ placeholder: "Type your text here...",
+ plugins: [
+ ...imagePlugins,
+ Alignment,
+ Autoformat,
+ Autosave,
+ Blocklist,
+ BlockQuote,
+ DialogVisibility,
+ Bold,
+ Code,
+ CodeBlock,
+ ContentLinks,
+ ContentClipboard,
+ DataFacade,
+ Differencing,
+ Essentials,
+ FindAndReplace,
+ Heading,
+ Highlight,
+ Indent,
+ Italic,
+ AutoLink,
+ Link,
+ LinkAttributes,
+ LinkImage,
+ LinkTarget,
+ CoreMediaStudioEssentials,
+ List,
+ Paragraph,
+ PasteContentPlugin,
+ PasteFromOffice,
+ RemoveFormat,
+ Strikethrough,
+ SourceEditing,
+ Subscript,
+ Superscript,
+ Table,
+ TableToolbar,
+ Underline,
+ CoreMediaFontMapper,
+ MockInputExamplePlugin,
+ MockStudioIntegration,
+ ],
+ toolbar: [
+ "undo",
+ "redo",
+ "|",
+ "heading",
+ "|",
+ "bold",
+ "italic",
+ "underline",
+ {
+ label: "More formatting",
+ icon: "threeVerticalDots",
+ items: ["strikethrough", "subscript", "superscript", "code"],
+ },
+ "highlight",
+ "removeFormat",
+ "|",
+ "link",
+ "|",
+ "alignment",
+ "blockQuote",
+ "codeBlock",
+ "|",
+ "insertTable",
+ "|",
+ "numberedList",
+ "bulletedList",
+ "outdent",
+ "indent",
+ "|",
+ "pasteContent",
+ "findAndReplace",
+ "blocklist",
+ "|",
+ "sourceEditing",
+ ],
+ alignment: {
+ options: [
+ {
+ name: "left",
+ className: "align--left",
+ },
+ {
+ name: "right",
+ className: "align--right",
+ },
+ {
+ name: "center",
+ className: "align--center",
+ },
+ {
+ name: "justify",
+ className: "align--justify",
+ },
+ ],
+ },
+ heading: {
+ options: [
+ {
+ model: "paragraph",
+ title: "Paragraph",
+ class: "ck-heading_paragraph",
+ },
+ {
+ model: "heading1",
+ view: "h1",
+ title: "Heading 1",
+ class: "ck-heading_heading1",
+ },
+ {
+ model: "heading2",
+ view: "h2",
+ title: "Heading 2",
+ class: "ck-heading_heading2",
+ },
+ {
+ model: "heading3",
+ view: "h3",
+ title: "Heading 3",
+ class: "ck-heading_heading3",
+ },
+ {
+ model: "heading4",
+ view: "h4",
+ title: "Heading 4",
+ class: "ck-heading_heading4",
+ },
+ {
+ model: "heading5",
+ view: "h5",
+ title: "Heading 5",
+ class: "ck-heading_heading5",
+ },
+ {
+ model: "heading6",
+ view: "h6",
+ title: "Heading 6",
+ class: "ck-heading_heading6",
+ },
+ ],
+ },
+ link: {
+ toolbar: ["linkPreview", "editLink", "|", "_self", "_blank", "_embed", "_other", "|", "unlink"],
+ defaultProtocol: "https://",
+ defaultTargets: [
+ {
+ filter: (url: string) => url.endsWith("#newTab"),
+ target: "_blank",
+ },
+ ],
+ ...linkAttributesConfig,
+ },
+ image: {
+ styles: {
+ options: [
+ {
+ name: "float-left",
+ icon: alignLeftIcon,
+ title: "Left-aligned",
+ className: "float--left",
+ modelElements: ["imageInline"],
+ },
+ {
+ name: "float-right",
+ icon: alignRightIcon,
+ title: "Right-aligned",
+ className: "float--right",
+ modelElements: ["imageInline"],
+ },
+ {
+ name: "float-none",
+ icon: withinTextIcon,
+ title: "Within Text",
+ className: "float--none",
+ modelElements: ["imageInline"],
+ },
+ {
+ name: "inline",
+ title: "Page default",
+ icon: pageDefaultIcon,
+ modelElements: ["imageInline"],
+ },
+ ],
+ },
+ toolbar: [
+ "imageStyle:float-left",
+ "imageStyle:float-right",
+ "imageStyle:float-none",
+ "|",
+ "imageStyle:inline",
+ "|",
+ "linkImage",
+ "imageTextAlternative",
+ "contentImageOpenInTab",
+ ],
+ },
+ table: {
+ contentToolbar: ["tableColumn", "tableRow", "mergeTableCells"],
+ },
+ language: {
+ ui: uiLanguage,
+ content: "en",
+ },
+ autosave: {
+ waitingTime: 1000,
+ },
+ dataFacade: {
+ save(): Promise {
+ return Promise.resolve();
+ },
+ },
+ [COREMEDIA_CONTEXT_KEY]: { uriPath: contextUriPath },
+ [COREMEDIA_RICHTEXT_CONFIG_KEY]: getRichTextConfig("latest"),
+ [COREMEDIA_RICHTEXT_SUPPORT_CONFIG_KEY]: {
+ aliases: [
+ {
+ name: "mark",
+ inherit: "span",
+ },
+ ],
+ },
+ // @ts-expect-error - TODO: Typing issues as it seems.
+ [COREMEDIA_LINK_CONFIG_KEY]: {
+ linkBalloon: {
+ keepOpen: {
+ ids: [linkBalloonScenario.keepOpenElementId, "inputExampleContentButton"],
+ classes: [linkBalloonScenario.keepOpenElementClass],
+ },
+ },
+ },
+ [COREMEDIA_MOCK_CONTENT_PLUGIN]: {
+ contents: [
+ {
+ id: 2,
+ name: "Some Example Document",
+ type: "document",
+ },
+ ],
+ },
+ });
+
+ // Different to in-production use, where differencing is only active in
+ // read-only view, the test scenarios use differencing in R/W view (as the
+ // former example application did). Activate it so augmented server-side
+ // diff data passes to the editing view.
+ if (editor.plugins.has(Differencing)) {
+ editor.plugins.get(Differencing).activateDifferencing();
+ }
+
+ return editor;
+ } catch (e) {
+ console.error("Caught error when creating Editor.", e);
+ throw Error(licenseKeyErrorMessage);
+ }
+};
diff --git a/tests/storybook/src/index.ts b/tests/storybook/src/index.ts
new file mode 100644
index 0000000000..b00430124a
--- /dev/null
+++ b/tests/storybook/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./runtime";
+export * from "./setup";
+export * from "./editors";
diff --git a/tests/storybook/src/runtime/index.ts b/tests/storybook/src/runtime/index.ts
new file mode 100644
index 0000000000..fe59334661
--- /dev/null
+++ b/tests/storybook/src/runtime/index.ts
@@ -0,0 +1,3 @@
+export * from "./scenario";
+export * from "./mountStory";
+export * from "./outputs";
diff --git a/tests/storybook/src/runtime/mountStory.ts b/tests/storybook/src/runtime/mountStory.ts
new file mode 100644
index 0000000000..63f6ba9ba7
--- /dev/null
+++ b/tests/storybook/src/runtime/mountStory.ts
@@ -0,0 +1,114 @@
+import type { ClassicEditor } from "ckeditor5";
+import {
+ EDITOR_ELEMENT_ID,
+ EDITOR_READY_ATTRIBUTE,
+ SCENARIO_CONTAINER_CLASS,
+} from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, type ScenarioArgs } from "./scenario";
+import { installOutputsHarness } from "./outputs";
+
+export { EDITOR_ELEMENT_ID, EDITOR_READY_ATTRIBUTE, SCENARIO_CONTAINER_CLASS };
+
+declare global {
+ interface Window {
+ /**
+ * The editor instance is exposed globally once ready, mirroring the former
+ * application behavior so handle-based access keeps working during the
+ * migration.
+ */
+ editor?: ClassicEditor;
+ }
+}
+
+/**
+ * Initializes an editor for a scenario. Concrete initializers (richtext /
+ * bbcode), together with the migrated mock-content setup, are provided by the
+ * Storybook story-setup utilities. The signature is the contract: given a host
+ * element and the resolved scenario args, return the ready editor instance and
+ * a cleanup function that undoes any side-effects (e.g. DOM elements added to
+ * document.body).
+ */
+export type ScenarioInitializer = (
+ host: HTMLElement,
+ args: ScenarioArgs,
+) => Promise<{ editor: ClassicEditor; cleanup: () => void }>;
+
+/**
+ * Resolves once the given element is attached to the document. CKEditor's
+ * `create` requires the source element to be connected to the DOM, but
+ * Storybook attaches a `render` result only after it has been returned. We
+ * therefore defer editor initialization until the host is connected.
+ *
+ * @param element - element to await attachment for
+ */
+const whenAttached = (element: HTMLElement): Promise =>
+ new Promise((resolve) => {
+ if (element.isConnected) {
+ resolve();
+ return;
+ }
+ const check = (): void => {
+ if (element.isConnected) {
+ resolve();
+ return;
+ }
+ requestAnimationFrame(check);
+ };
+ requestAnimationFrame(check);
+ });
+
+/**
+ * Renders a scenario container, mounts the editor host element into it and
+ * kicks off asynchronous initialization. The container is returned
+ * synchronously so it can be used directly as a Storybook `render` result.
+ *
+ * Readiness contract:
+ * - while initializing, the container has no `data-editor-ready` attribute,
+ * - on success, `window.editor` is set and `data-editor-ready="true"`,
+ * - on failure, `data-editor-ready="error"` is set and the error is logged.
+ *
+ * @param initialize - scenario initializer that creates the editor
+ * @param args - partial scenario args merged over {@link defaultScenarioArgs}
+ * @returns the scenario container element
+ */
+export const mountScenario = (initialize: ScenarioInitializer, args?: Partial): HTMLElement => {
+ const resolvedArgs: ScenarioArgs = { ...defaultScenarioArgs, ...args };
+
+ const container = document.createElement("div");
+ container.classList.add(SCENARIO_CONTAINER_CLASS);
+ container.removeAttribute(EDITOR_READY_ATTRIBUTE);
+
+ const host = document.createElement("div");
+ host.id = EDITOR_ELEMENT_ID;
+ container.appendChild(host);
+
+ void whenAttached(host)
+ .then(() => initialize(host, resolvedArgs))
+ .then(({ editor, cleanup: scenarioCleanup }) => {
+ window.editor = editor;
+ const outputsCleanup = installOutputsHarness(container, editor, resolvedArgs);
+ const cleanup = (): void => {
+ scenarioCleanup();
+ outputsCleanup();
+ if (window.editor === editor) {
+ delete window.editor;
+ }
+ };
+ // MutationObserver is the only way to detect removal without
+ // changing every story's render function.
+ const observer = new MutationObserver(() => {
+ if (!container.isConnected) {
+ observer.disconnect();
+ cleanup();
+ }
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ container.setAttribute(EDITOR_READY_ATTRIBUTE, "true");
+ })
+ .catch((error: unknown) => {
+ container.setAttribute(EDITOR_READY_ATTRIBUTE, "error");
+ console.error("Failed to initialize editor scenario", error);
+ });
+
+ return container;
+};
diff --git a/tests/storybook/src/runtime/outputs.ts b/tests/storybook/src/runtime/outputs.ts
new file mode 100644
index 0000000000..1a1327a7b5
--- /dev/null
+++ b/tests/storybook/src/runtime/outputs.ts
@@ -0,0 +1,132 @@
+import type { ClassicEditor } from "ckeditor5";
+import type { RichTextDataProcessor } from "@coremedia/ckeditor5-coremedia-richtext";
+import { OUTPUT_TEST_IDS, OUTPUTS_CONTAINER_CLASS, type ScenarioOutput } from "@coremedia/ckeditor5-itest-constants";
+import { getEditorData } from "../setup/editorData";
+import { getContentFormService } from "../setup/serviceAgent";
+import { validateIsDroppableInLinkBalloon, validateIsDroppableState } from "../setup/inputExample";
+import type { ScenarioArgs } from "./scenario";
+
+export { OUTPUT_TEST_IDS, OUTPUTS_CONTAINER_CLASS };
+
+/**
+ * Polling interval (ms) for outputs that have no change event to subscribe to
+ * (service state, droppability evaluation).
+ */
+const POLL_INTERVAL_MS = 100;
+
+const createOutputElement = (output: ScenarioOutput): HTMLElement => {
+ const element = document.createElement("pre");
+ element.dataset.test = OUTPUT_TEST_IDS[output];
+ return element;
+};
+
+const installEditorData = (editor: ClassicEditor, element: HTMLElement): (() => void) => {
+ const update = (): void => {
+ element.textContent = getEditorData(editor);
+ };
+ update();
+ editor.model.document.on("change:data", update);
+ return () => editor.model.document.off("change:data", update);
+};
+
+const installDataView = (editor: ClassicEditor, element: HTMLElement, data: string): (() => void) => {
+ const processor = editor.data.processor as RichTextDataProcessor;
+ const update = (_eventInfo: unknown, eventData: { dataView?: string }): void => {
+ if (typeof eventData.dataView === "string") {
+ element.textContent = eventData.dataView;
+ }
+ };
+ processor.on("richtext:toView", update);
+ // The scenario data was already set before the harness was installed, so the
+ // initial `richtext:toView` fired before we subscribed. Re-set the original
+ // scenario data once to populate the output. We deliberately re-set the
+ // loaded `data` (not `editor.getData()`): editing-only augmentations such as
+ // server-side differencing's `` are stripped from `getData()`,
+ // but must be visible in the data view.
+ editor.setData(data);
+ return () => processor.off("richtext:toView", update);
+};
+
+const installLastOpenedEntities = (editor: ClassicEditor, element: HTMLElement): (() => void) => {
+ let id: number | undefined;
+ void getContentFormService(editor).then((service) => {
+ const update = async (): Promise => {
+ element.textContent = JSON.stringify(await service.getLastOpenedEntities());
+ };
+ void update();
+ id = window.setInterval(() => void update(), POLL_INTERVAL_MS);
+ });
+ return () => clearInterval(id);
+};
+
+const installDroppableState = (editor: ClassicEditor, element: HTMLElement, uris: string[]): (() => void) => {
+ const update = (): void => {
+ element.textContent = JSON.stringify(validateIsDroppableState(editor, uris) ?? null);
+ };
+ update();
+ const id = window.setInterval(update, POLL_INTERVAL_MS);
+ return () => clearInterval(id);
+};
+
+const installDroppableInLinkBalloon = (editor: ClassicEditor, element: HTMLElement, uris: string[]): (() => void) => {
+ const update = (): void => {
+ element.textContent = JSON.stringify(validateIsDroppableInLinkBalloon(editor, uris) ?? null);
+ };
+ update();
+ const id = window.setInterval(update, POLL_INTERVAL_MS);
+ return () => clearInterval(id);
+};
+
+/**
+ * Renders the requested observable outputs as locator-readable DOM into the
+ * scenario container. Each output exposes a live value as the text content of a
+ * stable `[data-test="…"]` element, so Playwright tests can read editor and
+ * service state through locators instead of `page.evaluate`.
+ *
+ * @param container - scenario container element
+ * @param editor - live editor instance
+ * @param args - resolved scenario args
+ */
+export const installOutputsHarness = (
+ container: HTMLElement,
+ editor: ClassicEditor,
+ args: ScenarioArgs,
+): (() => void) => {
+ if (args.outputs.length === 0) {
+ return () => {};
+ }
+
+ const outputsContainer = document.createElement("div");
+ outputsContainer.classList.add(OUTPUTS_CONTAINER_CLASS);
+ container.appendChild(outputsContainer);
+
+ const cleanups: (() => void)[] = [];
+
+ for (const output of args.outputs) {
+ const element = createOutputElement(output);
+ outputsContainer.appendChild(element);
+
+ switch (output) {
+ case "editor-data":
+ cleanups.push(installEditorData(editor, element));
+ break;
+ case "data-view":
+ cleanups.push(installDataView(editor, element, args.data));
+ break;
+ case "last-opened-entities":
+ cleanups.push(installLastOpenedEntities(editor, element));
+ break;
+ case "is-droppable-state":
+ cleanups.push(installDroppableState(editor, element, args.droppableUris));
+ break;
+ case "is-droppable-in-link-balloon":
+ cleanups.push(installDroppableInLinkBalloon(editor, element, args.droppableUris));
+ break;
+ }
+ }
+
+ return () => {
+ cleanups.forEach((fn) => fn());
+ outputsContainer.remove();
+ };
+};
diff --git a/tests/storybook/src/runtime/scenario.ts b/tests/storybook/src/runtime/scenario.ts
new file mode 100644
index 0000000000..b68676b30a
--- /dev/null
+++ b/tests/storybook/src/runtime/scenario.ts
@@ -0,0 +1,123 @@
+import type {
+ InputExampleElement,
+ MockContentConfig,
+ MockExternalContent,
+} from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import type { ScenarioOutput } from "@coremedia/ckeditor5-itest-constants";
+
+export type { ScenarioOutput };
+
+/**
+ * A single item a scenario writes to the browser clipboard while mounting, so a
+ * test can paste a fully prepared payload without itself calling
+ * `page.evaluate`.
+ */
+export interface ClipboardScenarioItem {
+ /**
+ * MIME type of the clipboard payload, e.g. `"text/html"`.
+ */
+ type: string;
+ /**
+ * The payload written to the clipboard under {@link type}.
+ */
+ content: string;
+}
+
+/**
+ * Data type a story should initialize the editor with. Mirrors the former
+ * application `dataType` hash parameter.
+ */
+export type ScenarioDataType = "richtext" | "bbcode";
+
+/**
+ * UI language for the editor instance. Mirrors the former application
+ * `uiLanguage` hash parameter.
+ */
+export type ScenarioUiLanguage = "en" | "de";
+
+/**
+ * Declarative description of everything a story needs to set up a test
+ * scenario. This is the contract that replaces the imperative wrapper-based
+ * setup previously performed inside Playwright tests (for example
+ * `ApplicationWrapper`, `MockContentPluginWrapper`,
+ * `MockExternalContentPluginWrapper`).
+ *
+ * A story consumes these as Storybook _args_, so a scenario can be configured
+ * either in code (per dedicated story) or, where useful, via the story URL.
+ */
+export interface ScenarioArgs {
+ /**
+ * Which editor flavor to mount. Defaults to `"richtext"`.
+ */
+ dataType: ScenarioDataType;
+ /**
+ * Initial editor data to load (CoreMedia RichText XML or BBCode), matching
+ * the configured `dataType`. Defaults to empty data.
+ */
+ data: string;
+ /**
+ * UI language of the editor instance. Defaults to `"en"`.
+ */
+ uiLanguage: ScenarioUiLanguage;
+ /**
+ * Whether the editor starts in read-only mode. Defaults to `false`.
+ */
+ readOnly: boolean;
+ /**
+ * Mock contents to register with the mock studio backend before the editor
+ * loads data. Replaces `MockContentPluginWrapper.addContents`.
+ */
+ mockContents: MockContentConfig[];
+ /**
+ * Mock external contents to register with the mock studio backend. Replaces
+ * `MockExternalContentPluginWrapper.addContents`.
+ */
+ mockExternalContents: MockExternalContent[];
+ /**
+ * Words to pre-register with the mock blocklist service before the editor
+ * loads data. Replaces per-test `addBlockedWord` calls.
+ */
+ blockedWords: string[];
+ /**
+ * Draggable input-example elements to create as drag/paste sources when the
+ * scenario mounts. Replaces per-test `addInputExampleElement` calls.
+ */
+ inputExampleElements: InputExampleElement[];
+ /**
+ * Observable outputs the scenario should render as locator-readable DOM, so
+ * tests can read live editor/service values without `page.evaluate`. Empty by
+ * default (no harness rendered).
+ */
+ outputs: ScenarioOutput[];
+ /**
+ * Content uris evaluated for the `is-droppable-state` /
+ * `is-droppable-in-link-balloon` outputs. Only relevant when those outputs are
+ * requested.
+ */
+ droppableUris: string[];
+ /**
+ * When set, the scenario writes this item to the browser clipboard while
+ * mounting, so a test can paste a fully prepared payload (e.g. a Word HTML
+ * document) without itself calling `page.evaluate`. Requires the browser
+ * context to grant the `clipboard-write` permission (the Playwright config
+ * does). Defaults to `null` (no clipboard write).
+ */
+ clipboard: ClipboardScenarioItem | null;
+}
+
+/**
+ * Default scenario args. Stories spread these and override only what they need.
+ */
+export const defaultScenarioArgs: ScenarioArgs = {
+ dataType: "richtext",
+ data: "",
+ uiLanguage: "en",
+ readOnly: false,
+ mockContents: [],
+ mockExternalContents: [],
+ blockedWords: [],
+ inputExampleElements: [],
+ outputs: [],
+ droppableUris: [],
+ clipboard: null,
+};
diff --git a/tests/storybook/src/setup/applyScenario.ts b/tests/storybook/src/setup/applyScenario.ts
new file mode 100644
index 0000000000..8fef0d4cc7
--- /dev/null
+++ b/tests/storybook/src/setup/applyScenario.ts
@@ -0,0 +1,52 @@
+import type { ClassicEditor } from "ckeditor5";
+import type { ScenarioArgs } from "../runtime/scenario";
+import { registerMockContents } from "./mockContent";
+import { registerMockExternalContents } from "./mockExternalContent";
+import { setEditorData } from "./editorData";
+import { addBlockedWord } from "./serviceAgent";
+import { addInputExampleElement } from "./inputExample";
+import { writeClipboard } from "./clipboard";
+
+/**
+ * Reason id used when enabling read-only mode for a scenario.
+ */
+export const SCENARIO_READ_ONLY_LOCK_ID = "storybook-scenario";
+
+/**
+ * Applies the declarative scenario setup to a freshly created editor. This is
+ * the central in-page replacement for the imperative wrapper-based setup that
+ * Playwright tests previously performed (mock contents, external contents,
+ * blocked words, input-example drag sources, read-only state and initial data).
+ *
+ * Note: `dataType` and `uiLanguage` are creation-time concerns handled by the
+ * editor factory, not here.
+ *
+ * @param editor - freshly created editor instance
+ * @param args - resolved scenario args
+ */
+export const applyScenario = async (editor: ClassicEditor, args: ScenarioArgs): Promise<() => void> => {
+ if (args.mockContents.length > 0) {
+ registerMockContents(editor, ...args.mockContents);
+ }
+ if (args.mockExternalContents.length > 0) {
+ registerMockExternalContents(editor, args.mockExternalContents);
+ }
+ for (const word of args.blockedWords) {
+ await addBlockedWord(editor, word);
+ }
+ const inputElements = args.inputExampleElements.map((element) => addInputExampleElement(editor, element));
+ const cleanup = (): void => inputElements.forEach((el) => el.remove());
+ try {
+ if (args.readOnly) {
+ editor.enableReadOnlyMode(SCENARIO_READ_ONLY_LOCK_ID);
+ }
+ setEditorData(editor, args.data);
+ if (args.clipboard) {
+ await writeClipboard(args.clipboard);
+ }
+ return cleanup;
+ } catch (e) {
+ cleanup();
+ throw e;
+ }
+};
diff --git a/tests/storybook/src/setup/clipboard.ts b/tests/storybook/src/setup/clipboard.ts
new file mode 100644
index 0000000000..89ae5ea62a
--- /dev/null
+++ b/tests/storybook/src/setup/clipboard.ts
@@ -0,0 +1,30 @@
+import type { ClipboardScenarioItem } from "../runtime/scenario";
+
+/**
+ * Writes a single item to the browser clipboard as part of preparing a
+ * scenario. This is the in-page replacement for the former
+ * `ClipboardBrowserAccessor.write` that Playwright tests previously performed
+ * via `page.evaluate`: by writing during mount, a prepared story leaves the
+ * clipboard ready and the test only needs to paste.
+ *
+ * `Blob` and `ClipboardItem` are browser-only globals, so this must run in the
+ * Storybook runtime (browser), not in the Playwright (Node) process. The
+ * `clipboard-write` permission (and `clipboard-read` for the read-back
+ * verification below) must be granted on the browser context (the Playwright
+ * config does so); otherwise the write/read rejects.
+ *
+ * @param item - the clipboard item (MIME type + content) to write
+ */
+export const writeClipboard = async (item: ClipboardScenarioItem): Promise => {
+ const blob = new Blob([item.content], { type: item.type });
+ const clipboardItem = new ClipboardItem({ [item.type]: blob });
+ await navigator.clipboard.write([clipboardItem]);
+
+ // Fail early (so the scenario reports `data-editor-ready="error"`) if the
+ // write silently produced nothing, mirroring the former accessor's read-back
+ // verification.
+ const written = await navigator.clipboard.read();
+ if (written.length === 0) {
+ throw new Error(`Failed to write prepared clipboard item of type ${item.type}.`);
+ }
+};
diff --git a/tests/storybook/src/setup/editorData.ts b/tests/storybook/src/setup/editorData.ts
new file mode 100644
index 0000000000..c12055ec9e
--- /dev/null
+++ b/tests/storybook/src/setup/editorData.ts
@@ -0,0 +1,62 @@
+import type { ClassicEditor, Editor } from "ckeditor5";
+import type { RichTextDataProcessor } from "@coremedia/ckeditor5-coremedia-richtext";
+
+/**
+ * Sets editor data.
+ *
+ * In-page replacement for `ClassicEditorWrapper.setData`.
+ */
+export const setEditorData = (editor: Editor, value: string): void => {
+ editor.setData(value);
+};
+
+/**
+ * Reads editor data.
+ *
+ * In-page replacement for `ClassicEditorWrapper.getData`.
+ */
+export const getEditorData = (editor: Editor): string => editor.getData();
+
+/**
+ * Focuses the editor.
+ *
+ * In-page replacement for `EditorWrapper.focus`.
+ */
+export const focusEditor = (editor: Editor): void => {
+ editor.focus();
+};
+
+/**
+ * Sets the given data and resolves with the processed _data view_, that is the
+ * result of the data processor's `toView` transformation.
+ *
+ * In-page replacement for `ClassicEditorWrapper.setDataAndGetDataView`:
+ * - registers a one-time `richtext:toView` listener,
+ * - sets the data,
+ * - resolves with the produced data view (validating it matches the input).
+ *
+ * @param editor - live editor instance (rich text data processor expected)
+ * @param value - data to set
+ */
+export const setDataAndGetDataView = (editor: ClassicEditor, value: string): Promise =>
+ new Promise((resolve, reject) => {
+ const processor = editor.data.processor as RichTextDataProcessor;
+ processor.once("richtext:toView", (_eventInfo, eventData: { data?: unknown; dataView?: string }) => {
+ if (typeof eventData.dataView === "string" && "data" in eventData) {
+ if (eventData.data !== value) {
+ reject(
+ new Error(
+ `Unexpected data being processed. Concurrent changes applied?\n\tExpected: ${value}\n\tActual: ${String(
+ eventData.data,
+ )}`,
+ ),
+ );
+ return;
+ }
+ resolve(eventData.dataView);
+ return;
+ }
+ reject(new Error("richtext:toView provided unexpected data: " + JSON.stringify(eventData)));
+ });
+ editor.setData(value);
+ });
diff --git a/tests/storybook/src/setup/index.ts b/tests/storybook/src/setup/index.ts
new file mode 100644
index 0000000000..428426704c
--- /dev/null
+++ b/tests/storybook/src/setup/index.ts
@@ -0,0 +1,7 @@
+export * from "./plugins";
+export * from "./mockContent";
+export * from "./mockExternalContent";
+export * from "./inputExample";
+export * from "./serviceAgent";
+export * from "./editorData";
+export * from "./applyScenario";
diff --git a/tests/storybook/src/setup/inputExample.ts b/tests/storybook/src/setup/inputExample.ts
new file mode 100644
index 0000000000..02ab6af7dc
--- /dev/null
+++ b/tests/storybook/src/setup/inputExample.ts
@@ -0,0 +1,54 @@
+import type { Editor } from "ckeditor5";
+import {
+ MockInputExamplePlugin,
+ type InputExampleElement,
+} from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import type {
+ IsDroppableEvaluationResult,
+ IsLinkableEvaluationResult,
+} from "@coremedia/ckeditor5-coremedia-studio-integration";
+import { ensurePluginLoaded } from "./plugins";
+
+const inputExamplePlugin = (editor: Editor): MockInputExamplePlugin => {
+ ensurePluginLoaded(editor, MockInputExamplePlugin.pluginName);
+ return editor.plugins.get(MockInputExamplePlugin);
+};
+
+/**
+ * Creates a draggable input-example element and appends it to the document
+ * body, so it can serve as a drag source for drag-and-drop scenarios.
+ *
+ * In-page replacement for `MockInputExamplePluginWrapper.addInputExampleElement`.
+ *
+ * @param editor - live editor instance
+ * @param data - description of the input-example element
+ */
+export const addInputExampleElement = (editor: Editor, data: InputExampleElement): HTMLElement => {
+ const element = inputExamplePlugin(editor).createInsertElement(data);
+ document.body.append(element);
+ return element;
+};
+
+/**
+ * Evaluates the droppable state of the given uris in rich text.
+ *
+ * In-page replacement for `MockInputExamplePluginWrapper.validateIsDroppableState`.
+ *
+ * @param editor - live editor instance
+ * @param uris - content uris to evaluate
+ */
+export const validateIsDroppableState = (editor: Editor, uris: string[]): IsDroppableEvaluationResult | undefined =>
+ inputExamplePlugin(editor).ensureIsDroppableInRichTextIsEvaluated(uris);
+
+/**
+ * Evaluates the droppable state of the given uris in the link balloon.
+ *
+ * In-page replacement for `MockInputExamplePluginWrapper.validateIsDroppableInLinkBalloon`.
+ *
+ * @param editor - live editor instance
+ * @param uris - content uris to evaluate
+ */
+export const validateIsDroppableInLinkBalloon = (
+ editor: Editor,
+ uris: string[],
+): IsLinkableEvaluationResult | undefined => inputExamplePlugin(editor).ensureIsDroppableInLinkBalloon(uris);
diff --git a/tests/storybook/src/setup/mockContent.ts b/tests/storybook/src/setup/mockContent.ts
new file mode 100644
index 0000000000..45883608b1
--- /dev/null
+++ b/tests/storybook/src/setup/mockContent.ts
@@ -0,0 +1,21 @@
+import type { Editor } from "ckeditor5";
+import {
+ MockContentPlugin,
+ type MockContentConfig,
+} from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import { ensurePluginLoaded } from "./plugins";
+
+/**
+ * Registers mock contents with the mock studio backend.
+ *
+ * In-page replacement for `MockContentPluginWrapper.addContents`: instead of
+ * reaching into the editor via a Playwright handle, the story calls the plugin
+ * directly on the live editor instance.
+ *
+ * @param editor - live editor instance
+ * @param contents - content definitions to register
+ */
+export const registerMockContents = (editor: Editor, ...contents: MockContentConfig[]): void => {
+ ensurePluginLoaded(editor, MockContentPlugin.pluginName);
+ editor.plugins.get(MockContentPlugin).addContents(...contents);
+};
diff --git a/tests/storybook/src/setup/mockExternalContent.ts b/tests/storybook/src/setup/mockExternalContent.ts
new file mode 100644
index 0000000000..e782d2295c
--- /dev/null
+++ b/tests/storybook/src/setup/mockExternalContent.ts
@@ -0,0 +1,19 @@
+import type { Editor } from "ckeditor5";
+import {
+ MockExternalContentPlugin,
+ type MockExternalContent,
+} from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import { ensurePluginLoaded } from "./plugins";
+
+/**
+ * Registers mock external contents with the mock studio backend.
+ *
+ * In-page replacement for `MockExternalContentPluginWrapper.addContents`.
+ *
+ * @param editor - live editor instance
+ * @param externalContents - external content definitions to register
+ */
+export const registerMockExternalContents = (editor: Editor, externalContents: MockExternalContent[]): void => {
+ ensurePluginLoaded(editor, MockExternalContentPlugin.pluginName);
+ editor.plugins.get(MockExternalContentPlugin).addExternalContents(externalContents);
+};
diff --git a/tests/storybook/src/setup/plugins.ts b/tests/storybook/src/setup/plugins.ts
new file mode 100644
index 0000000000..6051abf152
--- /dev/null
+++ b/tests/storybook/src/setup/plugins.ts
@@ -0,0 +1,21 @@
+import type { Editor } from "ckeditor5";
+
+/**
+ * Ensures the named plugin is loaded in the given editor.
+ *
+ * This reproduces the helpful "available plugins" error message that the former
+ * handle-based wrappers raised, but runs in-page against the real editor
+ * instance instead of through a Playwright `JSHandle`.
+ *
+ * @param editor - editor to check
+ * @param pluginName - registered plugin name
+ * @throws Error if the plugin is not available
+ */
+export const ensurePluginLoaded = (editor: Editor, pluginName: string): void => {
+ if (!editor.plugins.has(pluginName)) {
+ const available = [...editor.plugins]
+ .map(([pluginConstructor, plugin]) => pluginConstructor.pluginName ?? `noname:${plugin.constructor.name}`)
+ .join(", ");
+ throw new Error(`Plugin ${pluginName} not available. Available plugins: ${available}`);
+ }
+};
diff --git a/tests/storybook/src/setup/serviceAgent.ts b/tests/storybook/src/setup/serviceAgent.ts
new file mode 100644
index 0000000000..f38a6aa7b0
--- /dev/null
+++ b/tests/storybook/src/setup/serviceAgent.ts
@@ -0,0 +1,59 @@
+import type { Editor } from "ckeditor5";
+import {
+ MockServiceAgentPlugin,
+ type MockBlocklistService,
+ type MockContentFormService,
+} from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import { ensurePluginLoaded } from "./plugins";
+
+const serviceAgentPlugin = (editor: Editor): MockServiceAgentPlugin => {
+ ensurePluginLoaded(editor, MockServiceAgentPlugin.pluginName);
+ return editor.plugins.get(MockServiceAgentPlugin);
+};
+
+/**
+ * Resolves the mock content form service.
+ *
+ * In-page replacement for
+ * `MockServiceAgentPluginWrapper.getContentFormServiceWrapper`.
+ *
+ * @param editor - live editor instance
+ */
+export const getContentFormService = (editor: Editor): Promise =>
+ serviceAgentPlugin(editor).getContentFormService();
+
+/**
+ * Reads the entities most recently triggered to be opened.
+ *
+ * In-page replacement for `ContentFormServiceWrapper.getLastOpenedEntities`.
+ *
+ * @param editor - live editor instance
+ */
+export const getLastOpenedEntities = async (editor: Editor): Promise => {
+ const service = await getContentFormService(editor);
+ return service.getLastOpenedEntities();
+};
+
+/**
+ * Resolves the mock blocklist service.
+ *
+ * In-page replacement for
+ * `MockServiceAgentPluginWrapper.getBlocklistServiceWrapper`.
+ *
+ * @param editor - live editor instance
+ */
+export const getBlocklistService = (editor: Editor): Promise =>
+ serviceAgentPlugin(editor).getMockBlocklistService();
+
+/**
+ * Pre-registers a blocked word with the mock blocklist service.
+ *
+ * In-page replacement for `BlocklistServiceWrapper.addWord`.
+ *
+ * @param editor - live editor instance
+ * @param word - word to block
+ */
+export const addBlockedWord = async (editor: Editor, word: string): Promise => {
+ const service = await getBlocklistService(editor);
+ await service.addToBlocklist(word);
+};
diff --git a/tests/storybook/stories/Smoke.stories.ts b/tests/storybook/stories/Smoke.stories.ts
new file mode 100644
index 0000000000..075f94cd62
--- /dev/null
+++ b/tests/storybook/stories/Smoke.stories.ts
@@ -0,0 +1,33 @@
+import type { Meta, StoryObj } from "@storybook/html";
+
+type SmokeStoryArgs = {
+ title: string;
+ description: string;
+};
+
+const meta: Meta = {
+ title: "Smoke/StorybookRuntime",
+ args: {
+ title: "Storybook runtime is available",
+ description: "This placeholder story confirms the new tests/storybook package setup.",
+ },
+ render: ({ title, description }) => {
+ const container = document.createElement("section");
+ container.setAttribute("data-testid", "storybook-smoke");
+
+ const heading = document.createElement("h1");
+ heading.textContent = title;
+
+ const paragraph = document.createElement("p");
+ paragraph.textContent = description;
+
+ container.append(heading, paragraph);
+ return container;
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/tests/storybook/stories/scenarios/Editor.stories.ts b/tests/storybook/stories/scenarios/Editor.stories.ts
new file mode 100644
index 0000000000..4c5fdd8f88
--- /dev/null
+++ b/tests/storybook/stories/scenarios/Editor.stories.ts
@@ -0,0 +1,53 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Base editor scenarios that mount a real CKEditor 5 instance through the
+ * migrated editor factories and scenario-setup utilities. These replace the
+ * former application as the runtime Playwright tests are migrated against.
+ *
+ * Each story renders a scenario container (`.storybook-editor-scenario`) and
+ * signals readiness via `data-editor-ready="true"`, the contract Playwright
+ * waits for.
+ */
+const meta: Meta = {
+ title: "Scenario/Editor",
+ args: {
+ ...defaultScenarioArgs,
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * Empty CoreMedia RichText editor (base scenario for richtext-based tests).
+ */
+export const RichText: Story = {
+ args: {
+ dataType: "richtext",
+ },
+};
+
+/**
+ * CoreMedia RichText editor preloaded with a simple paragraph.
+ */
+export const RichTextWithData: Story = {
+ args: {
+ dataType: "richtext",
+ data: richtext(p("Hello from CoreMedia RichText.")),
+ },
+};
+
+/**
+ * Empty BBCode editor (base scenario for bbcode-based tests).
+ */
+export const BBCode: Story = {
+ args: {
+ dataType: "bbcode",
+ },
+};
diff --git a/tests/storybook/stories/tests/Application.stories.ts b/tests/storybook/stories/tests/Application.stories.ts
new file mode 100644
index 0000000000..abc4a97d60
--- /dev/null
+++ b/tests/storybook/stories/tests/Application.stories.ts
@@ -0,0 +1,25 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenario for `Application.test.ts`: a plain, empty CoreMedia
+ * RichText editor used to assert the editor becomes available.
+ */
+const meta: Meta = {
+ title: "Tests/Application",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * Empty RichText editor.
+ */
+export const Default: Story = {};
diff --git a/tests/storybook/stories/tests/BBCode.stories.ts b/tests/storybook/stories/tests/BBCode.stories.ts
new file mode 100644
index 0000000000..8170099520
--- /dev/null
+++ b/tests/storybook/stories/tests/BBCode.stories.ts
@@ -0,0 +1,31 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { bbCodeScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenario for `BBCode.test.ts`: a BBCode editor preloaded with a
+ * single bold word, so the test can assert it renders as `` through
+ * locators only — no `page.evaluate`.
+ */
+const meta: Meta = {
+ title: "Tests/BBCode",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "bbcode",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * BBCode editor preloaded with a single bold word.
+ */
+export const BoldWord: Story = {
+ args: {
+ data: `[b]${bbCodeScenario.boldWord}[/b]`,
+ },
+};
diff --git a/tests/storybook/stories/tests/Blocklist.stories.ts b/tests/storybook/stories/tests/Blocklist.stories.ts
new file mode 100644
index 0000000000..5a95f71f75
--- /dev/null
+++ b/tests/storybook/stories/tests/Blocklist.stories.ts
@@ -0,0 +1,35 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { blocklistScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenario for `Blocklist.test.ts`: a CoreMedia RichText editor (the
+ * richtext factory loads the Blocklist plugin) preloaded with the three
+ * paragraphs the test operates on. Blocked words are added/removed by the test
+ * through the blocklist balloon UI via locators — no `page.evaluate`.
+ */
+const meta: Meta = {
+ title: "Tests/Blocklist",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * RichText editor preloaded with the paragraphs the test blocks/unblocks a word in.
+ */
+export const Default: Story = {
+ args: {
+ data: richtext(
+ `${p(blocklistScenario.helloWorldText)}${p(blocklistScenario.contentText)}${p(blocklistScenario.exampleText)}`,
+ ),
+ },
+};
diff --git a/tests/storybook/stories/tests/BlocklistCollapsed.stories.ts b/tests/storybook/stories/tests/BlocklistCollapsed.stories.ts
new file mode 100644
index 0000000000..af78ae5222
--- /dev/null
+++ b/tests/storybook/stories/tests/BlocklistCollapsed.stories.ts
@@ -0,0 +1,36 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { blocklistWordsScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenario for `BlocklistCollapsed.test.ts`: a CoreMedia RichText
+ * editor with the blocked word pre-registered and present in the prepared data,
+ * so the test can place a collapsed selection in the marker and assert the
+ * balloon through locators — no `page.evaluate`.
+ */
+const meta: Meta = {
+ title: "Tests/BlocklistCollapsed",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * RichText editor with a single blocked word marker.
+ */
+export const Default: Story = {
+ args: {
+ blockedWords: [blocklistWordsScenario.blockedWord],
+ data: richtext(
+ `${p(blocklistWordsScenario.notBlockedText)}${p(blocklistWordsScenario.blockedWord)}${p(blocklistWordsScenario.exampleText)}`,
+ ),
+ },
+};
diff --git a/tests/storybook/stories/tests/BlocklistExpandedKeyboard.stories.ts b/tests/storybook/stories/tests/BlocklistExpandedKeyboard.stories.ts
new file mode 100644
index 0000000000..097fcd7cf8
--- /dev/null
+++ b/tests/storybook/stories/tests/BlocklistExpandedKeyboard.stories.ts
@@ -0,0 +1,36 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { blocklistWordsScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenario for `BlocklistExpandedKeyboard.test.ts`: a CoreMedia
+ * RichText editor with two blocked words pre-registered and present in the
+ * prepared data, so the test can select all text and reveal the blocked words
+ * via the keyboard shortcut through locators — no `page.evaluate`.
+ */
+const meta: Meta = {
+ title: "Tests/BlocklistExpandedKeyboard",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * RichText editor with two blocked word markers.
+ */
+export const Default: Story = {
+ args: {
+ blockedWords: [blocklistWordsScenario.blockedWord, blocklistWordsScenario.anotherBlockedWord],
+ data: richtext(
+ `${p(blocklistWordsScenario.notBlockedText)}${p(`${blocklistWordsScenario.blockedWord},${blocklistWordsScenario.anotherBlockedWord}`)}${p(blocklistWordsScenario.exampleText)}`,
+ ),
+ },
+};
diff --git a/tests/storybook/stories/tests/BlocklistExpandedToolbar.stories.ts b/tests/storybook/stories/tests/BlocklistExpandedToolbar.stories.ts
new file mode 100644
index 0000000000..03897ca303
--- /dev/null
+++ b/tests/storybook/stories/tests/BlocklistExpandedToolbar.stories.ts
@@ -0,0 +1,36 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { blocklistWordsScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenario for `BlocklistExpandedToolbar.test.ts`: a CoreMedia
+ * RichText editor with two blocked words pre-registered and present in the
+ * prepared data, so the test can select all text and reveal the blocked words
+ * via the toolbar button through locators — no `page.evaluate`.
+ */
+const meta: Meta = {
+ title: "Tests/BlocklistExpandedToolbar",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * RichText editor with two blocked word markers.
+ */
+export const Default: Story = {
+ args: {
+ blockedWords: [blocklistWordsScenario.blockedWord, blocklistWordsScenario.anotherBlockedWord],
+ data: richtext(
+ `${p(blocklistWordsScenario.notBlockedText)}${p(`${blocklistWordsScenario.blockedWord},${blocklistWordsScenario.anotherBlockedWord}`)}${p(blocklistWordsScenario.exampleText)}`,
+ ),
+ },
+};
diff --git a/tests/storybook/stories/tests/ContentLink.stories.ts b/tests/storybook/stories/tests/ContentLink.stories.ts
new file mode 100644
index 0000000000..ace97a5ba4
--- /dev/null
+++ b/tests/storybook/stories/tests/ContentLink.stories.ts
@@ -0,0 +1,87 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { a, p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { contentUriPath } from "@coremedia/ckeditor5-coremedia-studio-integration";
+import { contentLinkContentName, contentLinkScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenarios for `ContentLink.test.ts`.
+ *
+ * Each story is fully prepared for the test that consumes it: the backing mock
+ * content and the content-link data are baked in, so the test only opens the
+ * story and drives the link balloon through locators — no `page.evaluate`. The
+ * predefined mock contents (e.g. "Some Document", "Some Folder") are provided
+ * automatically by `MockContentPlugin`. The literals are shared with the test
+ * via `@coremedia/ckeditor5-itest-constants` (`contentLinkScenario`).
+ */
+const meta: Meta = {
+ title: "Tests/ContentLink",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+type ContentLinkVariant = (typeof contentLinkScenario)[
+ | "renderWithName"
+ | "keyboardButtons"
+ | "emptyUrlForm"
+ | "emptyUrlKeyboard"];
+
+/**
+ * Builds a prepared content-link story: registers the backing mock content and
+ * loads a single content link pointing at it.
+ */
+const contentLinkStory = ({ id, linkText }: ContentLinkVariant): Story => ({
+ args: {
+ mockContents: [{ id, name: contentLinkContentName(linkText) }],
+ data: richtext(p(a(linkText, { "xlink:href": contentUriPath(id) }))),
+ },
+});
+
+/**
+ * Content link whose resolved content name is asserted in the balloon.
+ */
+export const RenderWithName: Story = contentLinkStory(contentLinkScenario.renderWithName);
+
+/**
+ * Content link used to walk all balloon buttons via keyboard.
+ */
+export const KeyboardButtons: Story = contentLinkStory(contentLinkScenario.keyboardButtons);
+
+/**
+ * Content link used to assert an empty url cannot be saved via the form.
+ */
+export const EmptyUrlForm: Story = contentLinkStory(contentLinkScenario.emptyUrlForm);
+
+/**
+ * Content link used to assert an empty url cannot be saved via keyboard.
+ */
+export const EmptyUrlKeyboard: Story = contentLinkStory(contentLinkScenario.emptyUrlKeyboard);
+
+/**
+ * Empty editor with the mock content present, used to add a content link with
+ * the keyboard only.
+ */
+export const AddWithKeyboard: Story = {
+ args: {
+ mockContents: [
+ {
+ id: contentLinkScenario.addWithKeyboard.id,
+ name: contentLinkContentName(contentLinkScenario.addWithKeyboard.linkText),
+ },
+ ],
+ },
+};
+
+/**
+ * Empty editor used to select a content from the link-field suggestions (backed
+ * by the predefined mock contents).
+ */
+export const SelectFromSuggestions: Story = {};
diff --git a/tests/storybook/stories/tests/Differencing.stories.ts b/tests/storybook/stories/tests/Differencing.stories.ts
new file mode 100644
index 0000000000..ddcdfbec71
--- /dev/null
+++ b/tests/storybook/stories/tests/Differencing.stories.ts
@@ -0,0 +1,98 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import type { MockContentConfig } from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import { PNG_BLUE_240x135, differencingScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated, fully prepared scenarios for `Differencing.test.ts`.
+ *
+ * Server-side differencing augments data with `` elements. Each
+ * story bakes one test's augmented richtext `data` and exposes the processed
+ * `data-view` observable output, so the test only opens the story and asserts
+ * through the `dataView` locator and editing-view locators — no `page.evaluate`.
+ * The augmented data and the strings the test asserts on are generated once and
+ * shared via `@coremedia/ckeditor5-itest-constants` (`differencingScenario`).
+ *
+ * Different to in-production use, where differencing is only active in read-only
+ * view, the scenarios use differencing in R/W view (the richtext factory loads
+ * the Differencing plugin).
+ */
+const meta: Meta = {
+ title: "Tests/Differencing",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+const dataViewStory = (data: string): Story => ({
+ args: {
+ data,
+ outputs: ["data-view"],
+ },
+});
+
+const { textCases, allAttributes, falsePositiveNewline, addedNewline, image } = differencingScenario;
+
+const [addition, removal, change, conflict] = textCases;
+
+/**
+ * Text difference of type "added".
+ */
+export const Addition: Story = dataViewStory(addition.data);
+
+/**
+ * Text difference of type "removed".
+ */
+export const Removal: Story = dataViewStory(removal.data);
+
+/**
+ * Text difference of type "changed".
+ */
+export const Change: Story = dataViewStory(change.data);
+
+/**
+ * Text difference of type "conflict".
+ */
+export const Conflict: Story = dataViewStory(conflict.data);
+
+/**
+ * Sequence of all difference types, used to assert that every `xdiff:span`
+ * attribute is forwarded to the editing view.
+ */
+export const AllAttributes: Story = dataViewStory(allAttributes.data);
+
+/**
+ * Bold word followed by a trailing-space difference, used to assert the
+ * false-positive newline is prevented.
+ */
+export const FalsePositiveNewline: Story = dataViewStory(falsePositiveNewline.data);
+
+/**
+ * Added newline, used to assert it passes to the editing view as an empty
+ * element.
+ */
+export const AddedNewline: Story = dataViewStory(addedNewline.data);
+
+/**
+ * Image whose `xdiff:changetype` augmentation must be passed to the editing
+ * view. Backed by a mock content providing the referenced image blob.
+ */
+export const ImageChangetype: Story = {
+ args: {
+ mockContents: [
+ {
+ id: image.contentId,
+ blob: PNG_BLUE_240x135,
+ name: image.contentName,
+ } as MockContentConfig,
+ ],
+ data: image.data,
+ },
+};
diff --git a/tests/storybook/stories/tests/DocumentLists.stories.ts b/tests/storybook/stories/tests/DocumentLists.stories.ts
new file mode 100644
index 0000000000..2a147cb896
--- /dev/null
+++ b/tests/storybook/stories/tests/DocumentLists.stories.ts
@@ -0,0 +1,168 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import {
+ a,
+ blobReference,
+ blockquote,
+ em,
+ img,
+ li,
+ ol,
+ p,
+ pre,
+ richtext,
+ span,
+ strong,
+ sub,
+ sup,
+ table,
+ td,
+ tr,
+ ul,
+} from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import {
+ documentListsCases,
+ documentListsConstants,
+ type DocumentListsCase,
+ type DocumentListType,
+} from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated, fully prepared scenarios for `DocumentLists.test.ts`.
+ *
+ * The test is heavily parametrized, so a per-file factory generates one story
+ * per case from the shared `documentListsCases` table
+ * (`@coremedia/ckeditor5-itest-constants`): each story bakes the matching
+ * richtext `data` and exposes the processed `data-view` observable output, so
+ * the test only opens the story and asserts through the `dataView` locator and
+ * editing-view locators — no `page.evaluate`.
+ */
+const meta: Meta = {
+ title: "Tests/DocumentLists",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+type ContentElement = (content: string, attributes?: Record) => string;
+
+const { text } = documentListsConstants;
+
+const listFn = (listType: DocumentListType): ContentElement => (listType === "ol" ? ol : ul) as ContentElement;
+
+const nestedTextFn = (element: string): ContentElement => {
+ switch (element) {
+ case "pre":
+ return pre as ContentElement;
+ case "blockquote":
+ return blockquote as ContentElement;
+ case "span":
+ return span as ContentElement;
+ case "strong":
+ return strong as ContentElement;
+ case "sub":
+ return sub as ContentElement;
+ case "sup":
+ return sup as ContentElement;
+ case "i":
+ return em as ContentElement;
+ default:
+ throw new Error(`Unsupported nested text element: ${element}`);
+ }
+};
+
+const buildData = (testCase: DocumentListsCase): string => {
+ const list = listFn(testCase.listType);
+ switch (testCase.kind) {
+ case "list-attributes":
+ return richtext(list(li(text), { ...documentListsConstants.attributeSet }));
+ case "li-attributes":
+ return richtext(list(li(text, { ...documentListsConstants.attributeSet })));
+ case "list-and-li-attributes":
+ return richtext(
+ list(li(text, { ...documentListsConstants.bothLiAttributes }), {
+ ...documentListsConstants.bothListAttributes(testCase.listType),
+ }),
+ );
+ case "nested-p-removed":
+ return richtext(list(li(p(text))));
+ case "nested-p-keep":
+ return richtext(list(li(p(text, { [testCase.attribute.key]: testCase.attribute.value }))));
+ case "nested-list":
+ return richtext(list(li(listFn(testCase.nested)(li(text)))));
+ case "nested-text-element":
+ return richtext(list(li(nestedTextFn(testCase.element)(text))));
+ case "nested-a":
+ return richtext(list(li(a(text, { "xlink:href": documentListsConstants.nestedLinkHref }))));
+ case "nested-img":
+ return richtext(
+ list(li(img({ "alt": text, "xlink:href": blobReference(documentListsConstants.nestedImageId) }))),
+ );
+ case "nested-table":
+ return richtext(list(li(table(tr(td(text))))));
+ }
+};
+
+const makeStory = (testCase: DocumentListsCase): Story => ({
+ args: {
+ data: buildData(testCase),
+ outputs: ["data-view"],
+ },
+});
+
+const casesById = new Map(documentListsCases.map((testCase) => [testCase.id, testCase]));
+
+const story = (id: string): Story => {
+ const testCase = casesById.get(id);
+ if (!testCase) {
+ throw new Error(`Unknown document-list case id: ${id}`);
+ }
+ return makeStory(testCase);
+};
+
+export const OlContainsAttributes: Story = story("ol-contains-attributes");
+export const OlLiContainsAttributes: Story = story("ol-li-contains-attributes");
+export const OlAndLiContainAttributes: Story = story("ol-and-li-contain-attributes");
+export const OlNestedPRemoved: Story = story("ol-nested-p-removed");
+export const OlNestedPKeepClass: Story = story("ol-nested-p-keep-class");
+export const OlNestedPKeepDir: Story = story("ol-nested-p-keep-dir");
+export const OlNestedPKeepLang: Story = story("ol-nested-p-keep-lang");
+export const OlNestedOl: Story = story("ol-nested-ol");
+export const OlNestedUl: Story = story("ol-nested-ul");
+export const OlNestedPre: Story = story("ol-nested-pre");
+export const OlNestedBlockquote: Story = story("ol-nested-blockquote");
+export const OlNestedSpan: Story = story("ol-nested-span");
+export const OlNestedStrong: Story = story("ol-nested-strong");
+export const OlNestedSub: Story = story("ol-nested-sub");
+export const OlNestedSup: Story = story("ol-nested-sup");
+export const OlNestedI: Story = story("ol-nested-i");
+export const OlNestedA: Story = story("ol-nested-a");
+export const OlNestedImg: Story = story("ol-nested-img");
+export const OlNestedTable: Story = story("ol-nested-table");
+
+export const UlContainsAttributes: Story = story("ul-contains-attributes");
+export const UlLiContainsAttributes: Story = story("ul-li-contains-attributes");
+export const UlAndLiContainAttributes: Story = story("ul-and-li-contain-attributes");
+export const UlNestedPRemoved: Story = story("ul-nested-p-removed");
+export const UlNestedPKeepClass: Story = story("ul-nested-p-keep-class");
+export const UlNestedPKeepDir: Story = story("ul-nested-p-keep-dir");
+export const UlNestedPKeepLang: Story = story("ul-nested-p-keep-lang");
+export const UlNestedOl: Story = story("ul-nested-ol");
+export const UlNestedUl: Story = story("ul-nested-ul");
+export const UlNestedPre: Story = story("ul-nested-pre");
+export const UlNestedBlockquote: Story = story("ul-nested-blockquote");
+export const UlNestedSpan: Story = story("ul-nested-span");
+export const UlNestedStrong: Story = story("ul-nested-strong");
+export const UlNestedSub: Story = story("ul-nested-sub");
+export const UlNestedSup: Story = story("ul-nested-sup");
+export const UlNestedI: Story = story("ul-nested-i");
+export const UlNestedA: Story = story("ul-nested-a");
+export const UlNestedImg: Story = story("ul-nested-img");
+export const UlNestedTable: Story = story("ul-nested-table");
diff --git a/tests/storybook/stories/tests/DragDrop.stories.ts b/tests/storybook/stories/tests/DragDrop.stories.ts
new file mode 100644
index 0000000000..2493ee3221
--- /dev/null
+++ b/tests/storybook/stories/tests/DragDrop.stories.ts
@@ -0,0 +1,158 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import type { MockContentConfig, MockExternalContent } from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import {
+ dragDropContentUri,
+ dragDropExternalUri,
+ dragDropInputElement,
+ dragDropScenario,
+} from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenarios for `DragDrop.test.ts`.
+ *
+ * Each story is fully prepared for the test that consumes it: the backing mock
+ * (or external) contents are registered, the draggable input-example element is
+ * created, the editor is preloaded with the standard example richtext data and
+ * the dropability evaluation is exposed via the `is-droppable-state` /
+ * `is-droppable-in-link-balloon` observable outputs. The test only opens the
+ * matching story and drives the drag-and-drop through locators — no
+ * `page.evaluate`. The fixtures are shared with the test via
+ * `@coremedia/ckeditor5-itest-constants` (`dragDropScenario`).
+ */
+const meta: Meta = {
+ title: "Tests/DragDrop",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+type ContentVariant = { class: string; contents: readonly MockContentConfig[] };
+type ExternalVariant = { class: string; external: readonly MockExternalContent[] };
+type ExternalContentItem = { externalId: number };
+
+const inputExampleElement = (inputElementClass: string, items: (number | ExternalContentItem)[]) => ({
+ label: dragDropInputElement.label,
+ tooltip: dragDropInputElement.tooltip,
+ items,
+ classes: ["input-content", inputElementClass],
+});
+
+/**
+ * Builds a prepared content drag story: registers the mock contents, a
+ * draggable input-example element referencing them and exposes the rich-text
+ * dropability evaluation. Image variants additionally expose `editor-data`.
+ */
+const contentDragStory = ({ class: inputElementClass, contents }: ContentVariant, exposeEditorData = false): Story => ({
+ args: {
+ mockContents: [...contents],
+ inputExampleElements: [inputExampleElement(inputElementClass, contents.map((content) => content.id))],
+ droppableUris: contents.map((content) => dragDropContentUri(content.id)),
+ data: richtext(),
+ outputs: exposeEditorData ? ["editor-data", "is-droppable-state"] : ["is-droppable-state"],
+ },
+});
+
+/**
+ * Builds a prepared external-content drag story: registers the mock external
+ * contents (and the imported content for already-imported ones), a draggable
+ * input-example element referencing them and exposes the rich-text dropability
+ * evaluation.
+ */
+const externalDragStory = ({ class: inputElementClass, external }: ExternalVariant): Story => {
+ const importedContents = external
+ .filter((content) => content.isAlreadyImported && content.contentAfterImport)
+ .map((content) => content.contentAfterImport as MockContentConfig);
+ return {
+ args: {
+ mockContents: importedContents,
+ mockExternalContents: [...external],
+ inputExampleElements: [
+ inputExampleElement(
+ inputElementClass,
+ external.map((content) => ({ externalId: content.id })),
+ ),
+ ],
+ droppableUris: external.map((content) => dragDropExternalUri(content.id)),
+ data: richtext(),
+ outputs: ["is-droppable-state"],
+ },
+ };
+};
+
+/**
+ * Single non-embeddable content, dropped as a link.
+ */
+export const OneLink: Story = contentDragStory(dragDropScenario.links.oneLink);
+
+/**
+ * Several non-embeddable contents (one slow-loading), dropped as links.
+ */
+export const SlowLinks: Story = contentDragStory(dragDropScenario.links.slowLinks);
+
+/**
+ * Several non-embeddable contents, dropped as links.
+ */
+export const MultipleLinks: Story = contentDragStory(dragDropScenario.links.multipleLinks);
+
+/**
+ * Single not-yet-imported external content, dropped as a link.
+ */
+export const ExternalLink: Story = externalDragStory(dragDropScenario.externalLinks.externalLink);
+
+/**
+ * Single already-imported external content, dropped as a link.
+ */
+export const AlreadyImportedExternalLink: Story = externalDragStory(dragDropScenario.externalLinks.alreadyImported);
+
+/**
+ * Several not-yet-imported external contents, dropped as links.
+ */
+export const MultipleExternalLinks: Story = externalDragStory(dragDropScenario.externalLinks.multipleExternal);
+
+/**
+ * Mixed already-imported and not-imported external contents, dropped as links.
+ */
+export const MultipleMixedExternalLinks: Story = externalDragStory(dragDropScenario.externalLinks.multipleMixed);
+
+/**
+ * Single not-yet-imported external content, dropped into the link balloon's url
+ * field. Exposes the link-balloon dropability evaluation.
+ */
+export const ExternalLinkIntoBalloon: Story = {
+ args: {
+ mockExternalContents: [...dragDropScenario.balloon.externalContent.external],
+ inputExampleElements: [
+ inputExampleElement(
+ dragDropScenario.balloon.externalContent.class,
+ dragDropScenario.balloon.externalContent.external.map((content) => ({ externalId: content.id })),
+ ),
+ ],
+ droppableUris: dragDropScenario.balloon.externalContent.external.map((content) => dragDropExternalUri(content.id)),
+ data: richtext(),
+ outputs: ["is-droppable-in-link-balloon"],
+ },
+};
+
+/**
+ * Single embeddable content, dropped as an image.
+ */
+export const OneImage: Story = contentDragStory(dragDropScenario.images.oneImage, true);
+
+/**
+ * Several embeddable contents, dropped as images.
+ */
+export const MultipleImages: Story = contentDragStory(dragDropScenario.images.multipleImages, true);
+
+/**
+ * Several embeddable contents (one slow-loading), dropped as images.
+ */
+export const SlowImages: Story = contentDragStory(dragDropScenario.images.slowImages, true);
diff --git a/tests/storybook/stories/tests/FontMapper.stories.ts b/tests/storybook/stories/tests/FontMapper.stories.ts
new file mode 100644
index 0000000000..0bc1b2d86e
--- /dev/null
+++ b/tests/storybook/stories/tests/FontMapper.stories.ts
@@ -0,0 +1,44 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+import { fontMapperClipboardHtml } from "./fontMapperTemplates";
+
+/**
+ * Dedicated scenarios for `FontMapper.test.ts`: an empty CoreMedia RichText
+ * editor (the richtext factory loads the FontMapper plugin) that exposes the
+ * `editor-data` observable output, so the test can assert the pasted/mapped
+ * content through a locator instead of `page.evaluate`.
+ *
+ * Each story is **fully prepared**: while mounting, it writes the corresponding
+ * Word-document HTML to the browser clipboard. The test therefore only focuses
+ * the editor and pastes — it neither reads the source HTML files nor touches the
+ * clipboard API itself.
+ */
+const meta: Meta = {
+ title: "Tests/FontMapper",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * Builds a prepared story whose clipboard already holds the given Word HTML.
+ */
+const clipboardStory = (clipboardHtml: string): Story => ({
+ args: {
+ outputs: ["editor-data"],
+ clipboard: { type: "text/html", content: clipboardHtml },
+ },
+});
+
+export const WordTemplate: Story = clipboardStory(fontMapperClipboardHtml["word-template"]);
+export const WordTemplateTable: Story = clipboardStory(fontMapperClipboardHtml["word-template-table"]);
+export const WordTemplateTableInheritFont: Story = clipboardStory(
+ fontMapperClipboardHtml["word-template-table-inherit-font"],
+);
diff --git a/tests/storybook/stories/tests/HelloEditor.stories.ts b/tests/storybook/stories/tests/HelloEditor.stories.ts
new file mode 100644
index 0000000000..d6b142eeb0
--- /dev/null
+++ b/tests/storybook/stories/tests/HelloEditor.stories.ts
@@ -0,0 +1,85 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { a, p, richtext, richTextData } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { contentUriPath } from "@coremedia/ckeditor5-coremedia-studio-integration";
+import { helloEditorScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenarios for `HelloEditor.test.ts`.
+ *
+ * Each story is fully prepared for the test that consumes it (initial data,
+ * mock contents and, where the test reads editor data back, the `editor-data`
+ * observable output). Tests interact and assert through Playwright locators
+ * only — no `page.evaluate`. The literals baked into these scenarios are shared
+ * with the test via `@coremedia/ckeditor5-itest-constants`
+ * (`helloEditorScenario`).
+ */
+
+const meta: Meta = {
+ title: "Tests/HelloEditor",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * RichText editor preloaded with the welcome showcase data (renders the
+ * `CoreMedia` showcase heading).
+ */
+export const Welcome: Story = {
+ args: {
+ data: richTextData.Welcome,
+ },
+};
+
+/**
+ * Empty RichText editor. Exposes `editor-data` so the test can assert the
+ * editor data of an empty editor is empty.
+ */
+export const Cleared: Story = {
+ args: {
+ data: "",
+ outputs: ["editor-data"],
+ },
+};
+
+/**
+ * RichText editor preloaded with a single external link.
+ */
+export const ExternalLink: Story = {
+ args: {
+ data: richtext(
+ p(a(helloEditorScenario.externalLinkText, { "xlink:href": helloEditorScenario.externalLinkTarget })),
+ ),
+ },
+};
+
+/**
+ * RichText editor preloaded with a single internal (content) link and the
+ * backing mock content.
+ */
+export const InternalLink: Story = {
+ args: {
+ mockContents: [
+ {
+ id: helloEditorScenario.internalLinkContentId,
+ name: `Document for ${helloEditorScenario.internalLinkText}`,
+ },
+ ],
+ data: richtext(
+ p(
+ a(helloEditorScenario.internalLinkText, {
+ "xlink:href": contentUriPath(helloEditorScenario.internalLinkContentId),
+ }),
+ ),
+ ),
+ },
+};
+
diff --git a/tests/storybook/stories/tests/Images.stories.ts b/tests/storybook/stories/tests/Images.stories.ts
new file mode 100644
index 0000000000..a2b6062373
--- /dev/null
+++ b/tests/storybook/stories/tests/Images.stories.ts
@@ -0,0 +1,179 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { a, blobReference, img, linkReference, p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import type { MockContentConfig } from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import { PNG_RED_240x135, imageMediaCases, imagesScenario, type ImageMediaCase } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated, fully prepared scenarios for `Images.test.ts`.
+ *
+ * Each story bakes the backing mock image content(s) and the image richtext
+ * `data` for one test, so the test only opens the story and drives the editor
+ * through locators — no `page.evaluate`. The "Media Representation" stories
+ * additionally expose the processed `data-view` output, and the enabled
+ * "Open in tab" story exposes the `last-opened-entities` output. The literals
+ * are shared with the test via `@coremedia/ckeditor5-itest-constants`.
+ */
+const meta: Meta = {
+ title: "Tests/Images",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+const imageData = (alt: string, href: string): string => richtext(p(img({ "alt": alt, "xlink:href": href })));
+
+const mediaStory = (testCase: ImageMediaCase): Story => {
+ const content: MockContentConfig = {
+ id: testCase.contentId,
+ name: testCase.name,
+ blob: testCase.blob,
+ initialDelayMs: testCase.initialDelayMs,
+ readable: testCase.readable,
+ };
+ return {
+ args: {
+ mockContents: [content],
+ data: imageData(testCase.name, blobReference(testCase.contentId)),
+ outputs: ["data-view"],
+ },
+ };
+};
+
+const mediaCasesById = new Map(imageMediaCases.map((testCase) => [testCase.id, testCase]));
+
+const mediaStoryById = (id: string): Story => {
+ const testCase = mediaCasesById.get(id);
+ if (!testCase) {
+ throw new Error(`Unknown image media case id: ${id}`);
+ }
+ return mediaStory(testCase);
+};
+
+/**
+ * Content with a fast-loading image.
+ */
+export const NormalFast: Story = mediaStoryById("normal-fast");
+
+/**
+ * Content with a slowly-loading image.
+ */
+export const SlowLoading: Story = mediaStoryById("slow-loading");
+
+/**
+ * Content with an unreadable image (resolves to the lock placeholder).
+ */
+export const Unreadable: Story = mediaStoryById("unreadable");
+
+/**
+ * Content with no image data (resolves to the empty placeholder).
+ */
+export const NoData: Story = mediaStoryById("no-data");
+
+/**
+ * Broken image with an empty `xlink:href`.
+ */
+export const InvalidHref: Story = {
+ args: {
+ data: imageData(imagesScenario.invalidHref.name, ""),
+ },
+};
+
+/**
+ * Single image used to walk through the alignment buttons.
+ */
+export const Alignment: Story = {
+ args: {
+ mockContents: [
+ {
+ id: imagesScenario.alignment.contentId,
+ blob: PNG_RED_240x135,
+ name: imagesScenario.alignment.contentName,
+ },
+ ],
+ data: imageData(imagesScenario.alignment.name, blobReference(imagesScenario.alignment.contentId)),
+ },
+};
+
+/**
+ * Readable image whose "Open in tab" action is enabled.
+ */
+export const OpenInTabEnabled: Story = {
+ args: {
+ mockContents: [
+ {
+ id: imagesScenario.openInTab.enabled.contentId,
+ blob: PNG_RED_240x135,
+ name: imagesScenario.openInTab.enabled.contentName,
+ },
+ ],
+ data: imageData(imagesScenario.openInTab.enabled.name, blobReference(imagesScenario.openInTab.enabled.contentId)),
+ outputs: ["last-opened-entities"],
+ },
+};
+
+/**
+ * Unreadable image whose "Open in tab" action is disabled.
+ */
+export const OpenInTabDisabled: Story = {
+ args: {
+ mockContents: [
+ {
+ id: imagesScenario.openInTab.disabled.contentId,
+ blob: PNG_RED_240x135,
+ name: imagesScenario.openInTab.disabled.contentName,
+ readable: false,
+ },
+ ],
+ data: imageData(imagesScenario.openInTab.disabled.name, blobReference(imagesScenario.openInTab.disabled.contentId)),
+ },
+};
+
+/**
+ * Image without a surrounding content link.
+ */
+export const LinksNoLink: Story = {
+ args: {
+ mockContents: [
+ {
+ id: imagesScenario.links.noLink.contentId,
+ blob: PNG_RED_240x135,
+ name: imagesScenario.links.noLink.contentName,
+ },
+ ],
+ data: imageData(imagesScenario.links.noLink.name, blobReference(imagesScenario.links.noLink.contentId)),
+ },
+};
+
+/**
+ * Image wrapped in a content link pointing at another content.
+ */
+export const LinksWithLink: Story = {
+ args: {
+ mockContents: [
+ {
+ id: imagesScenario.links.withLink.imageId,
+ blob: PNG_RED_240x135,
+ name: imagesScenario.links.withLink.contentName,
+ },
+ {
+ id: imagesScenario.links.withLink.linkedContentId,
+ name: imagesScenario.links.withLink.linkedContentName,
+ },
+ ],
+ data: richtext(
+ p(
+ a(img({ "alt": imagesScenario.links.withLink.name, "xlink:href": blobReference(imagesScenario.links.withLink.imageId) }), {
+ "xlink:href": linkReference(imagesScenario.links.withLink.linkedContentId),
+ }),
+ ),
+ ),
+ },
+};
diff --git a/tests/storybook/stories/tests/LinkBalloon.stories.ts b/tests/storybook/stories/tests/LinkBalloon.stories.ts
new file mode 100644
index 0000000000..732047444e
--- /dev/null
+++ b/tests/storybook/stories/tests/LinkBalloon.stories.ts
@@ -0,0 +1,75 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { a, p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { contentUriPath } from "@coremedia/ckeditor5-coremedia-studio-integration";
+import { linkBalloonScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenario for `LinkBalloon.test.ts`: a CoreMedia RichText editor
+ * preloaded with a content link (so the test can open the link balloon) and the
+ * helper fixture elements the test interacts with, rendered into the scenario
+ * container. The editor is configured (in the richtext factory) with the
+ * keep-open ids/classes the test relies on. The test only opens the story and
+ * asserts through locators — no `page.evaluate`.
+ */
+
+/**
+ * Creates a 50×50 fixture element the test can click/hover.
+ */
+const createFixtureElement = (init: { id?: string; className?: string; draggable?: boolean }): HTMLElement => {
+ const element = document.createElement("div");
+ if (init.id) {
+ element.id = init.id;
+ }
+ if (init.className) {
+ element.classList.add(init.className);
+ }
+ if (init.draggable) {
+ element.draggable = true;
+ }
+ element.style.width = "50px";
+ element.style.height = "50px";
+ element.style.backgroundColor = "#00ff00";
+ return element;
+};
+
+const renderLinkBalloonScenario = (args: ScenarioArgs): HTMLElement => {
+ const container = mountScenario(createEditorScenario, args);
+ container.append(
+ createFixtureElement({ id: linkBalloonScenario.draggableElementId, draggable: true }),
+ createFixtureElement({ id: linkBalloonScenario.keepOpenElementId }),
+ createFixtureElement({ className: linkBalloonScenario.keepOpenElementClass }),
+ );
+ return container;
+};
+
+const meta: Meta = {
+ title: "Tests/LinkBalloon",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: renderLinkBalloonScenario,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * RichText editor with a content link and the link-balloon test fixtures.
+ */
+export const Default: Story = {
+ args: {
+ mockContents: [
+ {
+ id: linkBalloonScenario.contentId,
+ name: `Document for test ${linkBalloonScenario.linkText}`,
+ },
+ ],
+ data: richtext(
+ p(a(linkBalloonScenario.linkText, { "xlink:href": contentUriPath(linkBalloonScenario.contentId) })),
+ ),
+ },
+};
diff --git a/tests/storybook/stories/tests/LinkUserInteraction.stories.ts b/tests/storybook/stories/tests/LinkUserInteraction.stories.ts
new file mode 100644
index 0000000000..e2022e18fd
--- /dev/null
+++ b/tests/storybook/stories/tests/LinkUserInteraction.stories.ts
@@ -0,0 +1,80 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { a, p, richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import { contentUriPath } from "@coremedia/ckeditor5-coremedia-studio-integration";
+import { linkInteractionContentName, linkUserInteractionScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated, fully prepared scenarios for `LinkUserInteraction.test.ts`.
+ *
+ * Each story bakes the link data, the backing mock content (for content links)
+ * and the read-only state for a pair of tests (mouse + keyboard), so the test
+ * only opens the story and drives the link through locators — no `page.evaluate`.
+ * Content-link stories expose the `last-opened-entities` output so the test can
+ * assert work-area-tab openings through a locator. The literals are shared with
+ * the test via `@coremedia/ckeditor5-itest-constants`.
+ */
+const meta: Meta = {
+ title: "Tests/LinkUserInteraction",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+const { externalLink, externalLinkReadOnly, contentLink, contentLinkReadOnly } = linkUserInteractionScenario;
+
+const externalLinkData = (linkText: string): string => richtext(p(a(linkText, { "xlink:href": externalLink.url })));
+
+const contentLinkData = (linkText: string, contentId: number): string =>
+ richtext(p(a(linkText, { "xlink:href": contentUriPath(contentId) })));
+
+/**
+ * Read/write editor with an external link.
+ */
+export const ExternalLink: Story = {
+ args: {
+ data: externalLinkData(externalLink.linkText),
+ },
+};
+
+/**
+ * Read-only editor with an external link.
+ */
+export const ExternalLinkReadOnly: Story = {
+ args: {
+ data: externalLinkData(externalLinkReadOnly.linkText),
+ readOnly: true,
+ },
+};
+
+/**
+ * Read/write editor with a content link.
+ */
+export const ContentLink: Story = {
+ args: {
+ mockContents: [{ id: contentLink.contentId, name: linkInteractionContentName(contentLink.linkText) }],
+ data: contentLinkData(contentLink.linkText, contentLink.contentId),
+ outputs: ["last-opened-entities"],
+ },
+};
+
+/**
+ * Read-only editor with a content link.
+ */
+export const ContentLinkReadOnly: Story = {
+ args: {
+ mockContents: [
+ { id: contentLinkReadOnly.contentId, name: linkInteractionContentName(contentLinkReadOnly.linkText) },
+ ],
+ data: contentLinkData(contentLinkReadOnly.linkText, contentLinkReadOnly.contentId),
+ readOnly: true,
+ outputs: ["last-opened-entities"],
+ },
+};
diff --git a/tests/storybook/stories/tests/PasteButton.stories.ts b/tests/storybook/stories/tests/PasteButton.stories.ts
new file mode 100644
index 0000000000..6f84f92c07
--- /dev/null
+++ b/tests/storybook/stories/tests/PasteButton.stories.ts
@@ -0,0 +1,92 @@
+import type { Meta, StoryObj } from "@storybook/html";
+import { richtext } from "@coremedia-internal/ckeditor5-coremedia-example-data";
+import type { MockContentConfig } from "@coremedia-internal/ckeditor5-coremedia-studio-integration-mock";
+import { pasteButtonInputElement, pasteButtonScenario } from "@coremedia/ckeditor5-itest-constants";
+import { defaultScenarioArgs, mountScenario, type ScenarioArgs } from "../../src/runtime";
+import { createEditorScenario } from "../../src/editors";
+
+/**
+ * Dedicated scenarios for `PasteButton.test.ts`.
+ *
+ * Each story is fully prepared for the test that consumes it: the backing mock
+ * contents are registered and the draggable input-example element is created,
+ * so the test only opens the story and drives the paste through locators — no
+ * `page.evaluate`. The fixtures (input-element classes, mock-content ids/names
+ * and image blobs) are shared with the test via
+ * `@coremedia/ckeditor5-itest-constants` (`pasteButtonScenario`).
+ */
+const meta: Meta = {
+ title: "Tests/PasteButton",
+ args: {
+ ...defaultScenarioArgs,
+ dataType: "richtext",
+ },
+ render: (args) => mountScenario(createEditorScenario, args),
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+type PasteButtonVariant = (typeof pasteButtonScenario)[keyof typeof pasteButtonScenario];
+
+/**
+ * Builds a prepared paste story: registers the variant's mock contents and a
+ * draggable input-example element referencing them, with the editor preloaded
+ * with the standard example richtext data. Image variants additionally expose
+ * the `editor-data` output so the test can assert the image downcast through a
+ * locator.
+ */
+const pasteButtonStory = ({ class: inputElementClass, contents }: PasteButtonVariant, exposeEditorData = false): Story => {
+ const mockContents = contents as readonly MockContentConfig[];
+ return {
+ args: {
+ mockContents: [...mockContents],
+ inputExampleElements: [
+ {
+ label: pasteButtonInputElement.label,
+ tooltip: pasteButtonInputElement.tooltip,
+ items: mockContents.map((content) => content.id),
+ classes: ["input-content", inputElementClass],
+ },
+ ],
+ data: richtext(),
+ outputs: exposeEditorData ? ["editor-data"] : [],
+ },
+ };
+};
+
+/**
+ * Single non-embeddable content, pasted as a link.
+ */
+export const OneLink: Story = pasteButtonStory(pasteButtonScenario.oneLink);
+
+/**
+ * Several non-embeddable contents (one slow-loading), pasted as links.
+ */
+export const SlowLinks: Story = pasteButtonStory(pasteButtonScenario.slowLinks);
+
+/**
+ * Several non-embeddable contents, pasted as links.
+ */
+export const MultipleLinks: Story = pasteButtonStory(pasteButtonScenario.multipleLinks);
+
+/**
+ * Single non-embeddable content, pasted as a link via keyboard shortcut.
+ */
+export const PasteViaKeyboardLink: Story = pasteButtonStory(pasteButtonScenario.pasteViaKeyboardLink);
+
+/**
+ * Single embeddable content, pasted as an image.
+ */
+export const OneImage: Story = pasteButtonStory(pasteButtonScenario.oneImage, true);
+
+/**
+ * Several embeddable contents, pasted as images.
+ */
+export const MultipleImages: Story = pasteButtonStory(pasteButtonScenario.multipleImages, true);
+
+/**
+ * Several embeddable contents (one slow-loading), pasted as images.
+ */
+export const SlowImages: Story = pasteButtonStory(pasteButtonScenario.slowImages, true);
diff --git a/tests/storybook/stories/tests/fontMapperTemplates.ts b/tests/storybook/stories/tests/fontMapperTemplates.ts
new file mode 100644
index 0000000000..0334cc0f1c
--- /dev/null
+++ b/tests/storybook/stories/tests/fontMapperTemplates.ts
@@ -0,0 +1,134 @@
+/**
+ * Word-document HTML fixtures for the FontMapper test stories. These were
+ * previously read as `.html` files by `FontMapper.test.ts`; they now live with
+ * the stories, because the prepared stories are what write them to the
+ * clipboard (the test no longer touches the files or the clipboard API).
+ *
+ * Each template contains a single `{PLACE_HOLDER}` inside a Symbol-font context.
+ * The FontMapper plugin maps the Symbol-font input character `$` to `∃`, so the
+ * prepared clipboard payload substitutes the placeholder with that input.
+ */
+
+const CHARACTER_PLACEHOLDER = "{PLACE_HOLDER}";
+
+/**
+ * Symbol-font input character pasted into the editor. The FontMapper plugin maps
+ * it to {@link FONT_MAPPER_EXPECTED_OUTPUT}.
+ */
+export const FONT_MAPPER_INPUT_SYMBOL = "$";
+
+/**
+ * Character the FontMapper plugin is expected to render for
+ * {@link FONT_MAPPER_INPUT_SYMBOL}.
+ */
+export const FONT_MAPPER_EXPECTED_OUTPUT = "∃";
+
+/**
+ * The simplest Word file: one symbol without any container.
+ */
+const wordTemplate = `
+
+
+
+
+
+
+
+
+{PLACE_HOLDER}
+
+
+`;
+
+/**
+ * A table with a span inside that carries the Symbol font directly. Validates
+ * replacement inside containers.
+ */
+const wordTemplateTable = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {PLACE_HOLDER}
+ |
+
+
+
+
+
+
+
+
+
+`;
+
+/**
+ * A table that carries the Symbol font, with a child span that must inherit it.
+ * Validates replacement for inherited font families.
+ */
+const wordTemplateTableInheritFont = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {PLACE_HOLDER}
+ |
+
+
+
+
+
+`;
+
+/**
+ * Substitutes the Symbol-font input character into a template, producing the
+ * exact `text/html` payload a prepared story writes to the clipboard.
+ */
+const prepareClipboardHtml = (template: string): string =>
+ template.replace(CHARACTER_PLACEHOLDER, FONT_MAPPER_INPUT_SYMBOL);
+
+/**
+ * Prepared `text/html` clipboard payloads, keyed by the kebab-cased story id
+ * suffix used for the matching FontMapper story export.
+ */
+export const fontMapperClipboardHtml = {
+ "word-template": prepareClipboardHtml(wordTemplate),
+ "word-template-table": prepareClipboardHtml(wordTemplateTable),
+ "word-template-table-inherit-font": prepareClipboardHtml(wordTemplateTableInheritFont),
+} as const;
diff --git a/tests/storybook/tsconfig.json b/tests/storybook/tsconfig.json
new file mode 100644
index 0000000000..d54449a53c
--- /dev/null
+++ b/tests/storybook/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "@coremedia/studio-client.build-config/tsconfig-shared.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "noEmit": true,
+ "strict": true,
+ "types": ["node"]
+ },
+ "include": [".storybook/**/*.ts", "src/**/*.ts", "stories/**/*.ts", "../../typings"]
+}
diff --git a/typings/types.d.ts b/typings/types.d.ts
index 5e52f805e5..c98c2aeb7f 100644
--- a/typings/types.d.ts
+++ b/typings/types.d.ts
@@ -1,4 +1,3 @@
-declare module "*.svg" {
- const content: string;
- export default content;
-}
+declare module "*.svg";
+
+declare module "*.css";