Commit 81c9e2d7915 for woocommerce

commit 81c9e2d791505022dc7f3c8a9f985e7af26da30c
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Thu May 7 10:10:55 2026 +0200

    Implement variation screen (#64595)

    * Register product_variations_classic_redesign feature flag

    * Add Init class and register wc-variations-classic assets

    * Inject React mount point in variations PHP template

    * Add React DataViews variations table

    * Add PHP unit tests for Init class

    * Add changelog entry for variations classic redesign Stage 1

    * Refactor variations table with DataViews composition and Values column

    * Fix variations table compliance with admin UI patterns

    * Remove CSS workaround for DataViews checkbox margin

    * Add @wordpress/ui to wp-min catalog and align admin package.json

    * Update pnpm-lock.yaml for @wordpress/ui and @wordpress/dataviews changes

    * Fix CI lint failures for variations-classic stage 1

    * Fix ESLint webpack resolver crash with pnpm override

    * Remove edit_variation fallback to classic UI

    * Fix Round 2 CI failures: typescript resolver and PHPStan baseline

    * Update pnpm-lock.yaml to reflect eslint-import-resolver-typescript override

    * Skip default attribute saves when fields aren't posted

    * Add horizontal padding to variations classic toolbar

    * Add placeholder image and border to variation thumbnail

    * Add empty edit modal triggered by variant title and Edit action

    * Render variation options as badges with single-line +N overflow

    * Move variation refresh in experimental product app

    * fix feature flag

    * clean up code

    * fix enqueue script

    * clean up code

    * Add variation view app entry point to webpack config

    * Add placeholder image fallback for products without images

    * Fix DataViews sticky footer in classic product editor variation view

    * Fix constant alignment in ProductVariationsClassicRedesign Init class

    * Remove separate variation view webpack entry in favour of main bundle

    * Remove placeholder image fallback from variation list

    * Move variation-view styles into main stylesheet

    * Restore images field render to original

    * Fix constant alignment in ProductVariationsClassicRedesign Init class

    * fix code

    ---------

    Co-authored-by: James Kemp <me@jckemp.com>

diff --git a/packages/js/experimental-products-app/changelog/add-variation-view-bundle b/packages/js/experimental-products-app/changelog/add-variation-view-bundle
new file mode 100644
index 00000000000..a62b51eda04
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-variation-view-bundle
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a dedicated variation view bundle for the classic product editor redesign.
diff --git a/packages/js/experimental-products-app/src/fields/registry.tsx b/packages/js/experimental-products-app/src/fields/registry.tsx
new file mode 100644
index 00000000000..a63ace4f81a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/registry.tsx
@@ -0,0 +1,156 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { fieldExtensions as buttonTextFieldExtensions } from './button_text/field';
+import { fieldExtensions as catalogVisibilityFieldExtensions } from './catalog_visibility/field';
+import { fieldExtensions as categoriesFieldExtensions } from './categories/field';
+import { fieldExtensions as crossSellIdsFieldExtensions } from './cross_sell_ids/field';
+import { fieldExtensions as dateOnSaleFromFieldExtensions } from './date_on_sale_from/field';
+import { fieldExtensions as dateOnSaleToFieldExtensions } from './date_on_sale_to/field';
+import { fieldExtensions as descriptionFieldExtensions } from './description/field';
+import { fieldExtensions as downloadableFieldExtensions } from './downloadable/field';
+import { fieldExtensions as downloadableCountFieldExtensions } from './downloadable_count/field';
+import { fieldExtensions as externalUrlFieldExtensions } from './external_url/field';
+import { fieldExtensions as featuredFieldExtensions } from './featured/field';
+import { fieldExtensions as heightFieldExtensions } from './height/field';
+import { fieldExtensions as imagesFieldExtensions } from './images/field';
+import { fieldExtensions as imagesCountFieldExtensions } from './images_count/field';
+import { fieldExtensions as inventorySummaryFieldExtensions } from './inventory_summary/field';
+import { fieldExtensions as lengthFieldExtensions } from './length/field';
+import { fieldExtensions as linkedProductsCountFieldExtensions } from './linked_products_count/field';
+import { fieldExtensions as manageStockFieldExtensions } from './manage_stock/field';
+import { fieldExtensions as nameFieldExtensions } from './name/field';
+import { fieldExtensions as onSaleFieldExtensions } from './on_sale/field';
+import { fieldExtensions as organizationSummaryFieldExtensions } from './organization_summary/field';
+import { fieldExtensions as priceFieldExtensions } from './price/field';
+import { fieldExtensions as priceSummaryFieldExtensions } from './price_summary/field';
+import { fieldExtensions as productStatusFieldExtensions } from './product_status/field';
+import { fieldExtensions as regularPriceFieldExtensions } from './regular_price/field';
+import { fieldExtensions as salePriceFieldExtensions } from './sale_price/field';
+import { fieldExtensions as scheduleSaleFieldExtensions } from './schedule_sale/field';
+import { fieldExtensions as shippingClassFieldExtensions } from './shipping_class/field';
+import { fieldExtensions as shippingSummaryFieldExtensions } from './shipping_summary/field';
+import { fieldExtensions as shortDescriptionFieldExtensions } from './short_description/field';
+import { fieldExtensions as skuFieldExtensions } from './sku/field';
+import { fieldExtensions as stockFieldExtensions } from './stock/field';
+import { fieldExtensions as stockQuantityFieldExtensions } from './stock_quantity/field';
+import { fieldExtensions as tagsFieldExtensions } from './tags/field';
+import { fieldExtensions as taxStatusFieldExtensions } from './tax_status/field';
+import type { ProductEntityRecord } from './types';
+import { fieldExtensions as typeFieldExtensions } from './type/field';
+import { fieldExtensions as upsellIdsFieldExtensions } from './upsell_ids/field';
+import { fieldExtensions as visibilitySummaryFieldExtensions } from './visibility_summary/field';
+import { fieldExtensions as weightFieldExtensions } from './weight/field';
+import { fieldExtensions as widthFieldExtensions } from './width/field';
+
+type ProductField = Field< ProductEntityRecord >;
+type ProductFieldExtensions = Partial< ProductField >;
+
+export const PRODUCT_FIELD_IDS = [
+	'name',
+	'short_description',
+	'description',
+	'images',
+	'images_count',
+	'product_status',
+	'sku',
+	'price',
+	'regular_price',
+	'sale_price',
+	'schedule_sale',
+	'date_on_sale_from',
+	'date_on_sale_to',
+	'on_sale',
+	'price_summary',
+	'stock',
+	'stock_quantity',
+	'manage_stock',
+	'inventory_summary',
+	'categories',
+	'tags',
+	'organization_summary',
+	'type',
+	'featured',
+	'catalog_visibility',
+	'visibility_summary',
+	'downloadable',
+	'downloadable_count',
+	'external_url',
+	'button_text',
+	'weight',
+	'length',
+	'width',
+	'height',
+	'shipping_class',
+	'shipping_summary',
+	'tax_status',
+	'upsell_ids',
+	'cross_sell_ids',
+	'linked_products_count',
+] as const;
+
+export type ProductFieldId = ( typeof PRODUCT_FIELD_IDS )[ number ];
+
+const PRODUCT_FIELD_EXTENSIONS: Record<
+	ProductFieldId,
+	ProductFieldExtensions
+> = {
+	name: nameFieldExtensions,
+	short_description: shortDescriptionFieldExtensions,
+	description: descriptionFieldExtensions,
+	images: imagesFieldExtensions,
+	images_count: imagesCountFieldExtensions,
+	product_status: productStatusFieldExtensions,
+	sku: skuFieldExtensions,
+	price: priceFieldExtensions as ProductFieldExtensions,
+	regular_price: regularPriceFieldExtensions,
+	sale_price: salePriceFieldExtensions,
+	schedule_sale: scheduleSaleFieldExtensions,
+	date_on_sale_from: dateOnSaleFromFieldExtensions,
+	date_on_sale_to: dateOnSaleToFieldExtensions,
+	on_sale: onSaleFieldExtensions,
+	price_summary: priceSummaryFieldExtensions,
+	stock: stockFieldExtensions,
+	stock_quantity: stockQuantityFieldExtensions,
+	manage_stock: manageStockFieldExtensions,
+	inventory_summary: inventorySummaryFieldExtensions,
+	categories: categoriesFieldExtensions,
+	tags: tagsFieldExtensions,
+	organization_summary: organizationSummaryFieldExtensions,
+	type: typeFieldExtensions,
+	featured: featuredFieldExtensions,
+	catalog_visibility: catalogVisibilityFieldExtensions,
+	visibility_summary: visibilitySummaryFieldExtensions,
+	downloadable: downloadableFieldExtensions,
+	downloadable_count: downloadableCountFieldExtensions,
+	external_url: externalUrlFieldExtensions,
+	button_text: buttonTextFieldExtensions,
+	weight: weightFieldExtensions,
+	length: lengthFieldExtensions,
+	width: widthFieldExtensions,
+	height: heightFieldExtensions,
+	shipping_class: shippingClassFieldExtensions,
+	shipping_summary: shippingSummaryFieldExtensions,
+	tax_status: taxStatusFieldExtensions,
+	upsell_ids: upsellIdsFieldExtensions,
+	cross_sell_ids: crossSellIdsFieldExtensions,
+	linked_products_count: linkedProductsCountFieldExtensions,
+};
+
+export function createProductField( id: ProductFieldId ): ProductField {
+	return {
+		id,
+		...PRODUCT_FIELD_EXTENSIONS[ id ],
+	};
+}
+
+export function createProductFields(
+	fieldIds: readonly ProductFieldId[]
+): ProductField[] {
+	return fieldIds.map( createProductField );
+}
diff --git a/packages/js/experimental-products-app/src/index.ts b/packages/js/experimental-products-app/src/index.ts
index 5880661f224..bfe124e06b3 100644
--- a/packages/js/experimental-products-app/src/index.ts
+++ b/packages/js/experimental-products-app/src/index.ts
@@ -1,2 +1,3 @@
 export * from './products';
 export { ProductsApp } from './app';
+export { initializeVariationView } from './variation-view-app';
diff --git a/packages/js/experimental-products-app/src/product-list/fields.tsx b/packages/js/experimental-products-app/src/product-list/fields.tsx
index 7cd45203ff1..c876dd5a394 100644
--- a/packages/js/experimental-products-app/src/product-list/fields.tsx
+++ b/packages/js/experimental-products-app/src/product-list/fields.tsx
@@ -1,146 +1,9 @@
-/**
- * External dependencies
- */
-import type { Field } from '@wordpress/dataviews';
-
 /**
  * Internal dependencies
  */
-import { fieldExtensions as buttonTextFieldExtensions } from '../fields/button_text/field';
-import { fieldExtensions as catalogVisibilityFieldExtensions } from '../fields/catalog_visibility/field';
-import { fieldExtensions as categoriesFieldExtensions } from '../fields/categories/field';
-import { fieldExtensions as crossSellIdsFieldExtensions } from '../fields/cross_sell_ids/field';
-import { fieldExtensions as dateOnSaleFromFieldExtensions } from '../fields/date_on_sale_from/field';
-import { fieldExtensions as dateOnSaleToFieldExtensions } from '../fields/date_on_sale_to/field';
-import { fieldExtensions as descriptionFieldExtensions } from '../fields/description/field';
-import { fieldExtensions as downloadableFieldExtensions } from '../fields/downloadable/field';
-import { fieldExtensions as downloadableCountFieldExtensions } from '../fields/downloadable_count/field';
-import { fieldExtensions as externalUrlFieldExtensions } from '../fields/external_url/field';
-import { fieldExtensions as featuredFieldExtensions } from '../fields/featured/field';
-import { fieldExtensions as heightFieldExtensions } from '../fields/height/field';
-import { fieldExtensions as imagesFieldExtensions } from '../fields/images/field';
-import { fieldExtensions as imagesCountFieldExtensions } from '../fields/images_count/field';
-import { fieldExtensions as inventorySummaryFieldExtensions } from '../fields/inventory_summary/field';
-import { fieldExtensions as lengthFieldExtensions } from '../fields/length/field';
-import { fieldExtensions as linkedProductsCountFieldExtensions } from '../fields/linked_products_count/field';
-import { fieldExtensions as manageStockFieldExtensions } from '../fields/manage_stock/field';
-import { fieldExtensions as nameFieldExtensions } from '../fields/name/field';
-import { fieldExtensions as onSaleFieldExtensions } from '../fields/on_sale/field';
-import { fieldExtensions as organizationSummaryFieldExtensions } from '../fields/organization_summary/field';
-import { fieldExtensions as priceFieldExtensions } from '../fields/price/field';
-import { fieldExtensions as priceSummaryFieldExtensions } from '../fields/price_summary/field';
-import { fieldExtensions as productStatusFieldExtensions } from '../fields/product_status/field';
-import { fieldExtensions as regularPriceFieldExtensions } from '../fields/regular_price/field';
-import { fieldExtensions as salePriceFieldExtensions } from '../fields/sale_price/field';
-import { fieldExtensions as scheduleSaleFieldExtensions } from '../fields/schedule_sale/field';
-import { fieldExtensions as shippingClassFieldExtensions } from '../fields/shipping_class/field';
-import { fieldExtensions as shippingSummaryFieldExtensions } from '../fields/shipping_summary/field';
-import { fieldExtensions as shortDescriptionFieldExtensions } from '../fields/short_description/field';
-import { fieldExtensions as skuFieldExtensions } from '../fields/sku/field';
-import { fieldExtensions as stockFieldExtensions } from '../fields/stock/field';
-import { fieldExtensions as stockQuantityFieldExtensions } from '../fields/stock_quantity/field';
-import { fieldExtensions as tagsFieldExtensions } from '../fields/tags/field';
-import { fieldExtensions as taxStatusFieldExtensions } from '../fields/tax_status/field';
-import type { ProductEntityRecord } from '../fields/types';
-import { fieldExtensions as typeFieldExtensions } from '../fields/type/field';
-import { fieldExtensions as upsellIdsFieldExtensions } from '../fields/upsell_ids/field';
-import { fieldExtensions as visibilitySummaryFieldExtensions } from '../fields/visibility_summary/field';
-import { fieldExtensions as weightFieldExtensions } from '../fields/weight/field';
-import { fieldExtensions as widthFieldExtensions } from '../fields/width/field';
-
-type ProductField = Field< ProductEntityRecord >;
-type ProductFieldExtensions = Partial< ProductField >;
-
-const PRODUCT_LIST_FIELD_IDS = [
-	'name',
-	'short_description',
-	'description',
-	'images',
-	'images_count',
-	'product_status',
-	'sku',
-	'price',
-	'regular_price',
-	'sale_price',
-	'schedule_sale',
-	'date_on_sale_from',
-	'date_on_sale_to',
-	'on_sale',
-	'price_summary',
-	'stock',
-	'stock_quantity',
-	'manage_stock',
-	'inventory_summary',
-	'categories',
-	'tags',
-	'organization_summary',
-	'type',
-	'featured',
-	'catalog_visibility',
-	'visibility_summary',
-	'downloadable',
-	'downloadable_count',
-	'external_url',
-	'button_text',
-	'weight',
-	'length',
-	'width',
-	'height',
-	'shipping_class',
-	'shipping_summary',
-	'tax_status',
-	'upsell_ids',
-	'cross_sell_ids',
-	'linked_products_count',
-] as const;
-
-const PRODUCT_LIST_FIELD_EXTENSIONS: Record< string, ProductFieldExtensions > =
-	{
-		name: nameFieldExtensions,
-		short_description: shortDescriptionFieldExtensions,
-		description: descriptionFieldExtensions,
-		images: imagesFieldExtensions,
-		images_count: imagesCountFieldExtensions,
-		product_status: productStatusFieldExtensions,
-		sku: skuFieldExtensions,
-		price: priceFieldExtensions as ProductFieldExtensions,
-		regular_price: regularPriceFieldExtensions,
-		sale_price: salePriceFieldExtensions,
-		schedule_sale: scheduleSaleFieldExtensions,
-		date_on_sale_from: dateOnSaleFromFieldExtensions,
-		date_on_sale_to: dateOnSaleToFieldExtensions,
-		on_sale: onSaleFieldExtensions,
-		price_summary: priceSummaryFieldExtensions,
-		stock: stockFieldExtensions,
-		stock_quantity: stockQuantityFieldExtensions,
-		manage_stock: manageStockFieldExtensions,
-		inventory_summary: inventorySummaryFieldExtensions,
-		categories: categoriesFieldExtensions,
-		tags: tagsFieldExtensions,
-		organization_summary: organizationSummaryFieldExtensions,
-		type: typeFieldExtensions,
-		featured: featuredFieldExtensions,
-		catalog_visibility: catalogVisibilityFieldExtensions,
-		visibility_summary: visibilitySummaryFieldExtensions,
-		downloadable: downloadableFieldExtensions,
-		downloadable_count: downloadableCountFieldExtensions,
-		external_url: externalUrlFieldExtensions,
-		button_text: buttonTextFieldExtensions,
-		weight: weightFieldExtensions,
-		length: lengthFieldExtensions,
-		width: widthFieldExtensions,
-		height: heightFieldExtensions,
-		shipping_class: shippingClassFieldExtensions,
-		shipping_summary: shippingSummaryFieldExtensions,
-		tax_status: taxStatusFieldExtensions,
-		upsell_ids: upsellIdsFieldExtensions,
-		cross_sell_ids: crossSellIdsFieldExtensions,
-		linked_products_count: linkedProductsCountFieldExtensions,
-	};
+import {
+	PRODUCT_FIELD_IDS as PRODUCT_LIST_FIELD_IDS,
+	createProductFields,
+} from '../fields/registry';

-export const productFields: ProductField[] = PRODUCT_LIST_FIELD_IDS.map(
-	( id ) => ( {
-		id,
-		...PRODUCT_LIST_FIELD_EXTENSIONS[ id ],
-	} )
-);
+export const productFields = createProductFields( PRODUCT_LIST_FIELD_IDS );
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 943196696c1..8376a7b3813 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -95,3 +95,5 @@
 	overflow: auto;
 	padding: 16px;
 }
+
+@import "./variation-view/style.scss";
diff --git a/packages/js/experimental-products-app/src/variation-view-app.tsx b/packages/js/experimental-products-app/src/variation-view-app.tsx
new file mode 100644
index 00000000000..d822b8a8896
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view-app.tsx
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { StrictMode, Suspense, createRoot, lazy } from '@wordpress/element';
+
+const VariationView = lazy( () =>
+	import(
+		/* webpackChunkName: "experimental-products-app-variation-view-main" */
+		'./variation-view'
+	).then( ( module ) => ( {
+		default: module.VariationView,
+	} ) )
+);
+
+/**
+ * Initializes the classic product editor variation view.
+ *
+ * @param {string} containerId DOM element ID.
+ * @param {number} productId   Parent product ID.
+ */
+export function initializeVariationView(
+	containerId: string,
+	productId: number
+): void {
+	const target = document.getElementById( containerId );
+
+	if ( ! target ) {
+		return undefined;
+	}
+
+	const root = createRoot( target );
+	root.render(
+		<StrictMode>
+			<Suspense fallback={ null }>
+				<VariationView productId={ productId } />
+			</Suspense>
+		</StrictMode>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/constants.ts b/packages/js/experimental-products-app/src/variation-view/constants.ts
new file mode 100644
index 00000000000..32eb997ef90
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/constants.ts
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import type { SupportedLayouts, ViewTable } from '@wordpress/dataviews';
+
+export const PAGE_SIZE = 20;
+
+export const DEFAULT_VARIATION_TABLE_TITLE_FIELD = 'name';
+export const DEFAULT_VARIATION_TABLE_MEDIA_FIELD = 'images';
+
+export const DEFAULT_VARIATION_TABLE_FIELDS = [
+	'variation_options',
+	'price',
+	'stock',
+] as const;
+
+export const DEFAULT_LAYOUTS = {
+	table: {
+		fields: [ ...DEFAULT_VARIATION_TABLE_FIELDS ],
+		layout: {
+			density: 'compact',
+			styles: {
+				price: {
+					align: 'end',
+				},
+			},
+		},
+	} satisfies NonNullable< SupportedLayouts[ 'table' ] >,
+};
+
+export const DEFAULT_VIEW: ViewTable = {
+	type: 'table',
+	page: 1,
+	perPage: PAGE_SIZE,
+	search: '',
+	filters: [],
+	titleField: DEFAULT_VARIATION_TABLE_TITLE_FIELD,
+	mediaField: DEFAULT_VARIATION_TABLE_MEDIA_FIELD,
+	fields: [ ...DEFAULT_VARIATION_TABLE_FIELDS ],
+	showMedia: true,
+	layout: DEFAULT_LAYOUTS.table.layout,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields.tsx b/packages/js/experimental-products-app/src/variation-view/fields.tsx
new file mode 100644
index 00000000000..c464c4e37d0
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields.tsx
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { createProductField, type ProductFieldId } from '../fields/registry';
+import type { VariationEntityRecord } from './types';
+
+const REUSED_VARIATION_FIELD_IDS = [
+	'name',
+	'sku',
+	'price',
+	'regular_price',
+	'sale_price',
+	'stock',
+	'stock_quantity',
+	'manage_stock',
+	'product_status',
+	'images',
+	'downloadable',
+	'weight',
+	'length',
+	'width',
+	'height',
+	'shipping_class',
+	'tax_status',
+] as const satisfies readonly ProductFieldId[];
+
+export const variationFields = REUSED_VARIATION_FIELD_IDS.map(
+	( id ) => createProductField( id ) as Field< VariationEntityRecord >
+);
diff --git a/packages/js/experimental-products-app/src/variation-view/index.tsx b/packages/js/experimental-products-app/src/variation-view/index.tsx
new file mode 100644
index 00000000000..d3483456187
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/index.tsx
@@ -0,0 +1,145 @@
+/**
+ * External dependencies
+ */
+import { DataViews, type Action, type View } from '@wordpress/dataviews';
+import { Notice } from '@wordpress/components';
+import { Button, Stack } from '@wordpress/ui';
+import { __ } from '@wordpress/i18n';
+import { useMemo, useState, useCallback } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+import { experimentalProductVariationsStore } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import { DEFAULT_LAYOUTS, DEFAULT_VIEW, PAGE_SIZE } from './constants';
+import { buildVariationViewQuery } from './query';
+import { normalizeVariation } from './normalization';
+import { variationFields } from './fields';
+import { VariationEditModal } from './variation-edit-modal';
+import type { VariationEntityRecord } from './types';
+
+const EMPTY_ARRAY: VariationEntityRecord[] = [];
+
+type VariationViewProps = {
+	productId: number;
+};
+
+export function VariationView( { productId }: VariationViewProps ) {
+	const [ view, setView ] = useState< View >( DEFAULT_VIEW );
+	const [ selection, setSelection ] = useState< string[] >( [] );
+	const [ editingVariation, setEditingVariation ] =
+		useState< VariationEntityRecord | null >( null );
+
+	const query = useMemo(
+		() => buildVariationViewQuery( view, productId ),
+		[ productId, view ]
+	);
+
+	const { records, totalItems, error } = useSelect(
+		( select ) => {
+			const store = select( experimentalProductVariationsStore );
+			return {
+				// @ts-expect-error missing types.
+				records: store.getProductVariations( query ),
+				// @ts-expect-error missing types.
+				totalItems: store.getProductVariationsTotalCount( query ),
+				// @ts-expect-error missing types.
+				error: store.getProductVariationsError( query ),
+			};
+		},
+		[ query ]
+	);
+
+	const variations = useMemo(
+		() => records?.map( normalizeVariation ) || EMPTY_ARRAY,
+		[ records ]
+	);
+	const perPage = view.perPage || PAGE_SIZE;
+	const paginationInfo = useMemo(
+		() => ( {
+			totalItems: totalItems ?? 0,
+			totalPages: Math.ceil( ( totalItems ?? 0 ) / perPage ),
+		} ),
+		[ perPage, totalItems ]
+	);
+
+	const handleEditVariation = useCallback(
+		( variation: VariationEntityRecord ) => {
+			setEditingVariation( variation );
+		},
+		[]
+	);
+
+	const actions: Action< VariationEntityRecord >[] = useMemo(
+		() => [
+			{
+				id: 'edit',
+				label: __( 'Edit', 'woocommerce' ),
+				isPrimary: true,
+				callback: ( items ) => handleEditVariation( items[ 0 ] ),
+			},
+			{
+				id: 'delete-variation',
+				label: __( 'Delete variation', 'woocommerce' ),
+				supportsBulk: true,
+				callback: () => {},
+			},
+		],
+		[ handleEditVariation ]
+	);
+
+	if ( error ) {
+		return (
+			<Notice status="error" isDismissible={ false }>
+				{ __( 'Failed to load variations.', 'woocommerce' ) }
+			</Notice>
+		);
+	}
+
+	return (
+		<div className="woocommerce-variation-view">
+			<DataViews
+				data={ variations }
+				fields={ variationFields }
+				view={ view }
+				onClickItem={ handleEditVariation }
+				onChangeView={ setView }
+				isLoading={ ! records }
+				paginationInfo={ paginationInfo }
+				getItemId={ ( item: VariationEntityRecord ) =>
+					String( item.id )
+				}
+				defaultLayouts={ DEFAULT_LAYOUTS }
+				actions={ actions }
+				selection={ selection }
+				onChangeSelection={ setSelection }
+			>
+				<Stack
+					direction="row"
+					align="center"
+					justify="space-between"
+					className="woocommerce-variation-view__toolbar"
+				>
+					<DataViews.Search
+						label={ __( 'Search variations', 'woocommerce' ) }
+					/>
+					<Stack direction="row" gap="xs">
+						<DataViews.ViewConfig />
+						<Button disabled>
+							{ __( 'Edit options', 'woocommerce' ) }
+						</Button>
+					</Stack>
+				</Stack>
+				<DataViews.Layout />
+				<DataViews.Footer />
+			</DataViews>
+			{ editingVariation && (
+				<VariationEditModal
+					variation={ editingVariation }
+					onClose={ () => setEditingVariation( null ) }
+				/>
+			) }
+		</div>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/normalization.test.ts b/packages/js/experimental-products-app/src/variation-view/normalization.test.ts
new file mode 100644
index 00000000000..35fb9f069ee
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/normalization.test.ts
@@ -0,0 +1,62 @@
+/**
+ * External dependencies
+ */
+import type { ProductVariation } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import { normalizeVariation } from './normalization';
+
+describe( 'normalizeVariation', () => {
+	it( 'maps variation image and product fields for reusable field renderers', () => {
+		const variation = {
+			id: 11,
+			parent_id: 99,
+			name: '',
+			attributes: [
+				{
+					id: 1,
+					name: 'Color',
+					slug: 'pa_color',
+					option: 'Blue',
+				},
+			],
+			image: {
+				id: 5,
+				src: 'https://example.com/blue.jpg',
+				name: 'Blue image',
+				alt: 'Blue',
+			},
+			manage_stock: 'parent',
+		} as ProductVariation;
+
+		const normalized = normalizeVariation( variation );
+
+		expect( normalized.name ).toBe( 'Blue' );
+		expect( normalized.type ).toBe( 'variation' );
+		expect( normalized.categories ).toEqual( [] );
+		expect( normalized.tags ).toEqual( [] );
+		expect( normalized.manage_stock ).toBe( false );
+		expect( normalized.images ).toEqual( [
+			expect.objectContaining( {
+				id: 5,
+				src: 'https://example.com/blue.jpg',
+				alt: 'Blue',
+				name: 'Blue image',
+			} ),
+		] );
+	} );
+
+	it( 'falls back to the variation ID when there are no attributes', () => {
+		const normalized = normalizeVariation( {
+			id: 12,
+			parent_id: 99,
+			name: '',
+			attributes: [],
+			manage_stock: false,
+		} as unknown as ProductVariation );
+
+		expect( normalized.name ).toBe( 'Variation #12' );
+	} );
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/normalization.ts b/packages/js/experimental-products-app/src/variation-view/normalization.ts
new file mode 100644
index 00000000000..7293c5b42d1
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/normalization.ts
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import type { ProductVariation } from '@woocommerce/data';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import type { VariationEntityRecord } from './types';
+
+function getVariationName( variation: ProductVariation ) {
+	if ( variation.name ) {
+		return variation.name;
+	}
+
+	if ( variation.attributes?.length > 0 ) {
+		return variation.attributes.map( ( attr ) => attr.option ).join( ', ' );
+	}
+
+	return sprintf(
+		/* translators: %d: variation ID. */
+		__( 'Variation #%d', 'woocommerce' ),
+		variation.id
+	);
+}
+
+function getVariationImages(
+	variation: ProductVariation
+): ProductEntityRecord[ 'images' ] {
+	if ( ! variation.image ) {
+		return [];
+	}
+
+	return [
+		{
+			id: variation.image.id,
+			src: variation.image.src,
+			alt: variation.image.alt,
+			name: variation.image.name,
+			thumbnail: variation.image.src,
+			date_created: variation.image.date_created || '',
+			date_created_gmt: variation.image.date_created_gmt || '',
+			date_modified: variation.image.date_modified || '',
+			date_modified_gmt: variation.image.date_modified_gmt || '',
+		},
+	];
+}
+
+export function normalizeVariation(
+	variation: ProductVariation
+): VariationEntityRecord {
+	return {
+		...variation,
+		categories: [],
+		tags: [],
+		images: getVariationImages( variation ),
+		name: getVariationName( variation ),
+		slug: String( variation.id ),
+		type: 'variation',
+		manage_stock: variation.manage_stock === true,
+	} as unknown as VariationEntityRecord;
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/query.test.ts b/packages/js/experimental-products-app/src/variation-view/query.test.ts
new file mode 100644
index 00000000000..c2ea782c2f6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/query.test.ts
@@ -0,0 +1,56 @@
+/**
+ * External dependencies
+ */
+import type { View } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { buildVariationViewQuery } from './query';
+
+describe( 'buildVariationViewQuery', () => {
+	it( 'maps the parent product and pagination params', () => {
+		const query = buildVariationViewQuery(
+			{
+				type: 'table',
+				page: 2,
+				perPage: 15,
+				filters: [],
+			} as View,
+			99
+		);
+
+		expect( query ).toEqual(
+			expect.objectContaining( {
+				product_id: 99,
+				page: 2,
+				per_page: 15,
+			} )
+		);
+	} );
+
+	it( 'maps search and supported sorting params', () => {
+		const query = buildVariationViewQuery(
+			{
+				type: 'table',
+				page: 1,
+				perPage: 20,
+				search: 'blue',
+				sort: {
+					field: 'name',
+					direction: 'asc',
+				},
+				filters: [],
+			} as View,
+			42
+		);
+
+		expect( query ).toEqual(
+			expect.objectContaining( {
+				search: 'blue',
+				order: 'asc',
+				orderby: 'title',
+			} )
+		);
+	} );
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/query.ts b/packages/js/experimental-products-app/src/variation-view/query.ts
new file mode 100644
index 00000000000..faa2a8e6ee7
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/query.ts
@@ -0,0 +1,38 @@
+/**
+ * External dependencies
+ */
+import type { View } from '@wordpress/dataviews';
+
+export type VariationViewQuery = {
+	product_id: number;
+	page: number;
+	per_page: number;
+	search?: string;
+	order?: 'asc' | 'desc';
+	orderby?: 'date' | 'id' | 'include' | 'title' | 'slug' | 'menu_order';
+};
+
+export function buildVariationViewQuery(
+	view: View,
+	productId: number
+): VariationViewQuery {
+	const query: VariationViewQuery = {
+		product_id: productId,
+		page: view.page ?? 1,
+		per_page: view.perPage ?? 20,
+	};
+
+	if ( view.search ) {
+		query.search = view.search;
+	}
+
+	if ( view.sort?.direction ) {
+		query.order = view.sort.direction;
+	}
+
+	if ( view.sort?.field ) {
+		query.orderby = view.sort.field === 'name' ? 'title' : 'menu_order';
+	}
+
+	return query;
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/style.scss b/packages/js/experimental-products-app/src/variation-view/style.scss
new file mode 100644
index 00000000000..7835fc2de85
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/style.scss
@@ -0,0 +1,50 @@
+// overflow: hidden on .panel-wrap creates a scroll container that prevents
+// position: sticky on .dataviews-footer from working relative to the viewport.
+// overflow: clip preserves the float-clearfix BFC without creating a scroll
+// container, so the footer can stick to the browser bottom.
+#woocommerce-product-data .panel-wrap:has(#woocommerce-variations-classic-root) {
+	overflow: clip;
+}
+
+.woocommerce-variation-view {
+	// DataViews is designed for a constrained editor viewport. Override to let
+	// the table grow to its natural height in the traditional metabox document flow.
+	.dataviews-wrapper {
+		height: auto;
+	}
+
+	.dataviews-layout__container {
+		overflow: visible;
+	}
+
+	&__toolbar {
+		padding: var(--wpds-dimension-padding-lg, 24px);
+	}
+
+	&__variation-options--empty {
+		color: var(--wpds-color-fg-content-neutral-weak, #757575);
+	}
+
+	&__variation-options-cell {
+		position: relative;
+		width: 100%;
+		min-width: 0;
+	}
+
+	&__variation-options-row {
+		flex-wrap: nowrap;
+		overflow: hidden;
+		min-width: 0;
+	}
+
+	&__variation-options-measure {
+		position: absolute;
+		inset-block-start: 0;
+		inset-inline-start: 0;
+		visibility: hidden;
+		pointer-events: none;
+		height: 0;
+		overflow: hidden;
+		white-space: nowrap;
+	}
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/types.ts b/packages/js/experimental-products-app/src/variation-view/types.ts
new file mode 100644
index 00000000000..9cb933bed54
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/types.ts
@@ -0,0 +1,23 @@
+/**
+ * External dependencies
+ */
+import type { ProductVariation } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+
+export type VariationEntityRecord = Omit<
+	ProductEntityRecord,
+	'attributes' | 'images' | 'manage_stock' | 'type'
+> &
+	Omit< ProductVariation, 'manage_stock' | 'type' > & {
+		attributes: ProductVariation[ 'attributes' ];
+		images: ProductEntityRecord[ 'images' ];
+		image?: ProductVariation[ 'image' ] | null;
+		manage_stock: boolean;
+		parent_id: number;
+		slug: string;
+		type: 'variation';
+	};
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-edit-modal.tsx b/packages/js/experimental-products-app/src/variation-view/variation-edit-modal.tsx
new file mode 100644
index 00000000000..d6dc6873c7c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-edit-modal.tsx
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Modal } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import type { VariationEntityRecord } from './types';
+
+type VariationEditModalProps = {
+	variation: VariationEntityRecord;
+	onClose: () => void;
+};
+
+export function VariationEditModal( {
+	variation,
+	onClose,
+}: VariationEditModalProps ) {
+	return (
+		// @ts-expect-error missing types.
+		<Modal
+			title={ sprintf(
+				/* translators: %d: variation ID. */
+				__( 'Edit variation #%d', 'woocommerce' ),
+				variation.id
+			) }
+			onRequestClose={ onClose }
+		>
+			{ /* TODO: variation edit form */ }
+		</Modal>
+	);
+}
diff --git a/plugins/woocommerce/changelog/add-product-variations-classic-redesign-stage1 b/plugins/woocommerce/changelog/add-product-variations-classic-redesign-stage1
new file mode 100644
index 00000000000..4835c9871ba
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-variations-classic-redesign-stage1
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Stage 1 of the variations classic redesign: a DataViews-based Variations tab in the classic product editor, behind the product_variations_classic_redesign feature flag (default off).
diff --git a/plugins/woocommerce/client/admin/config/core.json b/plugins/woocommerce/client/admin/config/core.json
index 221e75c4bfa..b62ff072f13 100644
--- a/plugins/woocommerce/client/admin/config/core.json
+++ b/plugins/woocommerce/client/admin/config/core.json
@@ -44,6 +44,7 @@
 		"launch-your-store": true,
 		"product-editor-template-system": false,
 		"use-wp-horizon": false,
-		"rest-api-v4": false
+		"rest-api-v4": false,
+		"product-variations-classic-redesign": false
 	}
 }
diff --git a/plugins/woocommerce/client/admin/config/development.json b/plugins/woocommerce/client/admin/config/development.json
index 4605c0171d3..3ce9855beaa 100644
--- a/plugins/woocommerce/client/admin/config/development.json
+++ b/plugins/woocommerce/client/admin/config/development.json
@@ -44,6 +44,7 @@
 		"launch-your-store": true,
 		"product-editor-template-system": false,
 		"use-wp-horizon": false,
-		"rest-api-v4": false
+		"rest-api-v4": false,
+		"product-variations-classic-redesign": false
 	}
 }
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
index 80ecbe9e01d..9e0d6b29297 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
@@ -5,12 +5,25 @@
  * @package WooCommerce\Admin\Metaboxes\Views
  */

+use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign\Init;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
 }

+if ( Features::exists( Init::FEATURE_ID ) ) {
+	?>
+	<div id="variable_product_options" class="panel wc-metaboxes-wrapper hidden">
+		<div id="variable_product_options_inner">
+			<div id="<?php echo esc_attr( Init::ROOT_ID ); ?>"></div>
+		</div>
+	</div>
+	<?php
+	return;
+}
+
 $add_attributes_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/icons/info.svg';
 $background_img_url     = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variation-background-image.svg';
 $arrow_img_url          = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variation-arrow.svg';
diff --git a/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php b/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php
new file mode 100644
index 00000000000..588a5e71035
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * WooCommerce Product Variations Classic Redesign
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
+
+/**
+ * Loads assets for the product variations classic redesign feature.
+ */
+class Init {
+	const FEATURE_ID    = 'product-variations-classic-redesign';
+	const SCRIPT_HANDLE = 'wc-experimental-products-app';
+	const SCRIPT_PATH   = 'experimental-products-app';
+	const ROOT_ID       = 'woocommerce-variations-classic-root';
+
+	/**
+	 * Constructor
+	 */
+	public function __construct() {
+		if ( ! is_admin() ) {
+			return;
+		}
+
+		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 20 );
+	}
+
+	/**
+	 * Returns true if we are on a product edit screen.
+	 */
+	public static function is_product_edit_page(): bool {
+		$screen = get_current_screen();
+		return $screen && 'product' === $screen->post_type && 'post' === $screen->base;
+	}
+
+	/**
+	 * Returns true if the user has requested legacy editing for a specific variation.
+	 */
+	public static function is_legacy_variation_edit(): bool {
+		// phpcs:disable WordPress.Security.NonceVerification
+		return isset( $_GET['edit_variation'] ) && is_numeric( $_GET['edit_variation'] );
+		// phpcs:enable WordPress.Security.NonceVerification
+	}
+
+	/**
+	 * Enqueue scripts and styles for the variations table.
+	 */
+	public function enqueue_scripts(): void {
+		if ( ! self::is_product_edit_page() || self::is_legacy_variation_edit() ) {
+			return;
+		}
+
+		wp_enqueue_script( self::SCRIPT_HANDLE );
+		wp_enqueue_style( self::SCRIPT_HANDLE );
+
+		global $post;
+		$product_id = $post ? $post->ID : 0;
+
+		wp_add_inline_script(
+			self::SCRIPT_HANDLE,
+			sprintf(
+				'window.wc.experimentalProductsApp.initializeVariationView( %s, %d );',
+				wp_json_encode( self::ROOT_ID ),
+				$product_id
+			),
+			'after'
+		);
+	}
+}