diff --git a/apps/ui/src/components/sidebar-layout/index.test.tsx b/apps/ui/src/components/sidebar-layout/index.test.tsx
index 92fef395d0..af8e1e2edc 100644
--- a/apps/ui/src/components/sidebar-layout/index.test.tsx
+++ b/apps/ui/src/components/sidebar-layout/index.test.tsx
@@ -13,6 +13,10 @@ vi.mock( '@/components/app-message-cards', () => ( {
AppMessageCardsDot: () => null,
} ) );
+vi.mock( '@/components/studio-beta-menu', () => ( {
+ StudioBetaMenu: () => null,
+} ) );
+
vi.mock( '@/components/site-list', () => ( {
SiteList: () => ,
} ) );
diff --git a/apps/ui/src/components/sidebar-layout/index.tsx b/apps/ui/src/components/sidebar-layout/index.tsx
index 2ef2b0c2e6..bd994902ad 100644
--- a/apps/ui/src/components/sidebar-layout/index.tsx
+++ b/apps/ui/src/components/sidebar-layout/index.tsx
@@ -8,6 +8,7 @@ import { AppToasts } from '@/components/app-toasts';
import { ResizeHandle, ResizeOverlay } from '@/components/resize-handle';
import { SidebarHeader } from '@/components/sidebar-header';
import { SiteList } from '@/components/site-list';
+import { StudioBetaMenu } from '@/components/studio-beta-menu';
import { UserMenu } from '@/components/user-menu';
import { useConnector } from '@/data/core';
import { useColorScheme } from '@/hooks/use-color-scheme';
@@ -98,6 +99,9 @@ export function SidebarLayout( {
{ ! effectiveCollapsed ? (
) : null }
+ { ! effectiveCollapsed ? (
+
+ ) : null }
diff --git a/apps/ui/src/components/sidebar-layout/style.module.css b/apps/ui/src/components/sidebar-layout/style.module.css
index 15fdc196f3..c9f403ad26 100644
--- a/apps/ui/src/components/sidebar-layout/style.module.css
+++ b/apps/ui/src/components/sidebar-layout/style.module.css
@@ -82,6 +82,15 @@
padding: var(--wpds-dimension-padding-sm) calc(var(--wpds-dimension-padding-lg) - var(--wpds-dimension-padding-sm)) 0;
}
+/* The beta affordance stays in normal footer flow, below transient/persistent
+ messages and above Settings, so it never overlays a toast or checklist.
+ Margin, not padding: this class lands on the bubble itself, which owns its
+ own padding. */
+.sidebarBeta {
+ margin: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-lg)
+ var(--wpds-dimension-padding-xs);
+}
+
/* Sidebar-specific indicator treatment: match the content card's vertical
extent instead of running through the frame gaps above/below it (keep the
12px in sync with --panel-frame-gap in preview-split-frame). The handle is
diff --git a/apps/ui/src/components/studio-beta-menu/index.test.tsx b/apps/ui/src/components/studio-beta-menu/index.test.tsx
new file mode 100644
index 0000000000..e8d0ffd1d3
--- /dev/null
+++ b/apps/ui/src/components/studio-beta-menu/index.test.tsx
@@ -0,0 +1,80 @@
+import '@testing-library/jest-dom/vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useConnector } from '@/data/core';
+import { useAppGlobals } from '@/data/queries/use-app-globals';
+import { REPORT_ISSUE_URL } from '@/lib/docs-links';
+import { StudioBetaMenu } from './index';
+
+vi.mock( '@/data/core', () => ( {
+ useConnector: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-app-globals', () => ( {
+ useAppGlobals: vi.fn(),
+} ) );
+
+const useConnectorMock = vi.mocked( useConnector, { partial: true } );
+const useAppGlobalsMock = vi.mocked( useAppGlobals, { partial: true } );
+
+describe( 'StudioBetaMenu', () => {
+ const disableAgenticUi = vi.fn().mockResolvedValue( undefined );
+ const openExternalUrl = vi.fn().mockResolvedValue( undefined );
+
+ beforeEach( () => {
+ vi.clearAllMocks();
+ useConnectorMock.mockReturnValue( {
+ capabilities: { switchToClassicUi: true },
+ disableAgenticUi,
+ openExternalUrl,
+ } as never );
+ useAppGlobalsMock.mockReturnValue( {
+ data: { platform: 'darwin', isWindowsStore: false, appVersion: '1.18.0-beta1' },
+ } as never );
+ } );
+
+ it( 'offers feedback and classic Studio actions from its menu', async () => {
+ render( );
+
+ expect( screen.queryByRole( 'menuitem', { name: 'Report an issue' } ) ).not.toBeInTheDocument();
+ fireEvent.click( screen.getByRole( 'button', { name: 'Studio Beta options' } ) );
+ expect( await screen.findByText( 'Studio 1.18.0-beta1' ) ).toBeVisible();
+
+ fireEvent.click( await screen.findByRole( 'menuitem', { name: 'Switch to classic' } ) );
+ expect( disableAgenticUi ).toHaveBeenCalledTimes( 1 );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Studio Beta options' } ) );
+ fireEvent.click( await screen.findByRole( 'menuitem', { name: 'Report an issue' } ) );
+ expect( openExternalUrl ).toHaveBeenCalledWith( REPORT_ISSUE_URL );
+ } );
+
+ it( 'hides the classic action when the host cannot switch experiences', async () => {
+ useConnectorMock.mockReturnValue( {
+ capabilities: { switchToClassicUi: false },
+ disableAgenticUi,
+ openExternalUrl,
+ } as never );
+
+ render( );
+ fireEvent.click( screen.getByRole( 'button', { name: 'Studio Beta options' } ) );
+
+ expect( await screen.findByRole( 'menuitem', { name: 'Report an issue' } ) ).toBeVisible();
+ expect(
+ screen.queryByRole( 'menuitem', { name: 'Switch to classic' } )
+ ).not.toBeInTheDocument();
+ } );
+
+ // Browser targets report no installed app version; the menu drops the row
+ // rather than rendering a bare "Studio".
+ it( 'omits the version row when the host reports no app version', async () => {
+ useAppGlobalsMock.mockReturnValue( {
+ data: { platform: 'browser', isWindowsStore: false },
+ } as never );
+
+ render( );
+ fireEvent.click( screen.getByRole( 'button', { name: 'Studio Beta options' } ) );
+
+ expect( await screen.findByRole( 'menuitem', { name: 'Report an issue' } ) ).toBeVisible();
+ expect( screen.queryByText( /^Studio / ) ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/apps/ui/src/components/studio-beta-menu/index.tsx b/apps/ui/src/components/studio-beta-menu/index.tsx
new file mode 100644
index 0000000000..ecf10f4425
--- /dev/null
+++ b/apps/ui/src/components/studio-beta-menu/index.tsx
@@ -0,0 +1,51 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { clsx } from 'clsx';
+import * as Menu from '@/components/menu';
+import { useConnector } from '@/data/core';
+import { useAppGlobals } from '@/data/queries/use-app-globals';
+import { REPORT_ISSUE_URL } from '@/lib/docs-links';
+import styles from './style.module.css';
+
+export function StudioBetaMenu( { className }: { className?: string } ) {
+ const connector = useConnector();
+ const { data: appGlobals } = useAppGlobals();
+
+ return (
+
+
+ }
+ >
+ { __( 'Beta' ) }
+
+
+ { connector.capabilities.switchToClassicUi ? (
+ void connector.disableAgenticUi() }>
+ { __( 'Switch to classic' ) }
+
+ ) : null }
+ void connector.openExternalUrl( REPORT_ISSUE_URL ) }>
+ { __( 'Report an issue' ) }
+
+ { appGlobals?.appVersion ? (
+ <>
+
+
+
+ {
+ // translators: %s: the running Studio version, e.g. "1.18.0-beta1".
+ sprintf( __( 'Studio %s' ), appGlobals.appVersion )
+ }
+
+
+ >
+ ) : null }
+
+
+ );
+}
diff --git a/apps/ui/src/components/studio-beta-menu/style.module.css b/apps/ui/src/components/studio-beta-menu/style.module.css
new file mode 100644
index 0000000000..00bf8b0059
--- /dev/null
+++ b/apps/ui/src/components/studio-beta-menu/style.module.css
@@ -0,0 +1,69 @@
+/* Speech bubble anchored to the sidebar footer: brand fill, notch on the
+ bottom-left corner pointing at Settings. */
+.trigger {
+ --beta-bubble-bg: var(--wpds-color-bg-interactive-brand-strong);
+ --beta-bubble-border: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 35%, transparent);
+ /* Read by the global `button:focus:not(:focus-visible)` reset in index.css,
+ which would otherwise repaint the bubble with WP UI's Button colors. */
+ --wp-ui-button-background-color: var(--beta-bubble-bg);
+ --wp-ui-button-border-color: var(--beta-bubble-border);
+
+ display: flex;
+ align-items: center;
+ width: max-content;
+ max-width: 100%;
+ box-sizing: border-box;
+ padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
+ appearance: none;
+ background-color: var(--beta-bubble-bg);
+ border: var(--wpds-border-width-xs) solid var(--beta-bubble-border);
+ border-radius: 14px 14px 14px 4px;
+ box-shadow: var(--wpds-elevation-md);
+ color: var(--wpds-color-fg-content-neutral);
+ font: inherit;
+ font-size: 11px;
+ line-height: var(--wpds-typography-line-height-xs);
+ cursor: var(--wpds-cursor-control);
+ outline: none;
+ animation: beta-enter 160ms ease-out;
+}
+
+/* Darken toward the window chrome rather than toward the label. The sidebar
+ renders in a fixed dark theme scope, so `fg-content-neutral` is near-white
+ there and mixing it in would drop the label under the 4.5:1 contrast floor. */
+.trigger:hover {
+ --beta-bubble-bg: color-mix(
+ in srgb,
+ var(--wpds-color-bg-interactive-brand-strong) 88%,
+ var(--app-chrome-bg) 12%
+ );
+}
+
+/* Hold the resting fill while pressed or while the menu is open, including
+ under the global focus reset above, which paints `--beta-bubble-bg`. */
+.trigger:active,
+.trigger[data-popup-open] {
+ --beta-bubble-bg: var(--wpds-color-bg-interactive-brand-strong);
+}
+
+.trigger:focus-visible {
+ outline: var(--wpds-border-width-xs) solid var(--wpds-color-fg-content-neutral);
+ outline-offset: 2px;
+}
+
+.label {
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+@keyframes beta-enter {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts
index edcdfc6fa3..b61bcd32c1 100644
--- a/apps/ui/src/data/core/types.ts
+++ b/apps/ui/src/data/core/types.ts
@@ -525,6 +525,9 @@ export interface UserPreferences {
export interface AppGlobals {
platform: string;
isWindowsStore: boolean;
+ // Supplied by the desktop host; browser targets do not have an installed
+ // Studio app version to report.
+ appVersion?: string;
}
// Subset of UserPreferences that callers can actually mutate. `locale` is