diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts
index 0b2d00abee..d16afe5be8 100644
--- a/apps/studio/src/ipc-handlers.ts
+++ b/apps/studio/src/ipc-handlers.ts
@@ -196,6 +196,7 @@ export {
downloadSyncBackup,
exportSiteForPush,
fetchSyncableWpcomSites,
+ fetchSyncableWpcomSitesPage,
getConnectedWpcomSites,
pauseSyncUpload,
pullSiteFromLive,
diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts
index f33ff10efa..23b074ef4a 100644
--- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts
+++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts
@@ -12,7 +12,11 @@ import {
} from '@studio/common/lib/connected-sites';
import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import { getCurrentUserId } from '@studio/common/lib/shared-config';
-import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
+import {
+ fetchSyncableSites,
+ fetchSyncableSitesPage,
+ type SyncableSitesPage,
+} from '@studio/common/lib/sync/sync-api';
import wpcomFactory from '@studio/common/lib/wpcom-factory';
import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory';
import { SyncSite } from '@studio/common/types/sync';
@@ -524,7 +528,24 @@ export async function fetchSyncableWpcomSites( _event: IpcMainInvokeEvent ): Pro
if ( ! token?.accessToken ) {
throw new Error( 'Authentication required to fetch WordPress.com sites.' );
}
- return fetchSyncableSites( token.accessToken );
+ // Pass the already-connected remote IDs so the transform can mark those
+ // sites 'already-connected' instead of offering them as syncable again.
+ const connectedSites = await getAllConnectedWpcomSitesForCurrentUser();
+ const connectedSiteIds = connectedSites.map( ( site ) => site.id );
+ return fetchSyncableSites( token.accessToken, { connectedSiteIds } );
+}
+
+export async function fetchSyncableWpcomSitesPage(
+ _event: IpcMainInvokeEvent,
+ options: { page?: number; perPage?: number; search?: string } = {}
+): Promise< SyncableSitesPage > {
+ const token = await getAuthenticationToken();
+ if ( ! token?.accessToken ) {
+ throw new Error( 'Authentication required to fetch WordPress.com sites.' );
+ }
+ const connectedSites = await getAllConnectedWpcomSitesForCurrentUser();
+ const connectedSiteIds = connectedSites.map( ( site ) => site.id );
+ return fetchSyncableSitesPage( token.accessToken, { ...options, connectedSiteIds } );
}
export async function getConnectedWpcomSites(
diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts
index b92f50046d..ddc428fe70 100644
--- a/apps/studio/src/preload.ts
+++ b/apps/studio/src/preload.ts
@@ -135,6 +135,8 @@ const api: IpcApi = {
getConnectedWpcomSites: ( localSiteId ) =>
ipcRendererInvoke( 'getConnectedWpcomSites', localSiteId ),
fetchSyncableWpcomSites: () => ipcRendererInvoke( 'fetchSyncableWpcomSites' ),
+ fetchSyncableWpcomSitesPage: ( options ) =>
+ ipcRendererInvoke( 'fetchSyncableWpcomSitesPage', options ),
pullSiteFromLive: ( siteFolder, remoteSiteId ) =>
ipcRendererInvoke( 'pullSiteFromLive', siteFolder, remoteSiteId ),
addSyncOperation: ( id, status ) => ipcRendererSend( 'addSyncOperation', id, status ),
diff --git a/apps/ui/package.json b/apps/ui/package.json
index 02ca4db048..8719231a4d 100644
--- a/apps/ui/package.json
+++ b/apps/ui/package.json
@@ -17,6 +17,7 @@
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-persist-client": "^5.96.2",
"@tanstack/react-router": "^1.120.14",
+ "@wordpress/a11y": "^4.47.0",
"@wordpress/api-fetch": "^7.47.0",
"@wordpress/components": "^34.0.0",
"@wordpress/core-data": "^7.47.0",
diff --git a/apps/ui/src/components/busy-overlay/index.tsx b/apps/ui/src/components/busy-overlay/index.tsx
new file mode 100644
index 0000000000..cacaddc34e
--- /dev/null
+++ b/apps/ui/src/components/busy-overlay/index.tsx
@@ -0,0 +1,15 @@
+import styles from './style.module.css';
+
+/**
+ * Transparent full-window shield that blocks pointer interaction while a
+ * long-running action (site creation, connect-and-pull) finishes. Pair it
+ * with disabled/loading states on the triggering button — it deliberately
+ * covers everything, including the onboarding close button, so a stray
+ * click can't interrupt the work mid-flight.
+ */
+export function BusyOverlay( { active }: { active: boolean } ) {
+ if ( ! active ) {
+ return null;
+ }
+ return
;
+}
diff --git a/apps/ui/src/components/busy-overlay/style.module.css b/apps/ui/src/components/busy-overlay/style.module.css
new file mode 100644
index 0000000000..04daad1f9b
--- /dev/null
+++ b/apps/ui/src/components/busy-overlay/style.module.css
@@ -0,0 +1,7 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ /* Above the onboarding close button (7) and footer actions (6). */
+ z-index: 8;
+ cursor: progress;
+}
diff --git a/apps/ui/src/components/onboarding-footer/index.tsx b/apps/ui/src/components/onboarding-footer/index.tsx
new file mode 100644
index 0000000000..733f57b651
--- /dev/null
+++ b/apps/ui/src/components/onboarding-footer/index.tsx
@@ -0,0 +1,19 @@
+import styles from './style.module.css';
+import type { ReactNode } from 'react';
+
+/**
+ * Fixed action bar docked to the bottom of the onboarding flow, with a
+ * progressive-blur scrim so content scrolls away underneath it — the
+ * apps/ui counterpart of the desktop renderer's Add Site stepper.
+ * `CreateSiteForm` renders one around its actions; selector-style routes
+ * render their own. The first child (Back) is pinned bottom-left and the
+ * remaining actions sit bottom-right.
+ */
+export function OnboardingFooter( { children }: { children: ReactNode } ) {
+ return (
+ <>
+
+
{ children }
+ >
+ );
+}
diff --git a/apps/ui/src/components/onboarding-footer/style.module.css b/apps/ui/src/components/onboarding-footer/style.module.css
new file mode 100644
index 0000000000..0ba5f127c1
--- /dev/null
+++ b/apps/ui/src/components/onboarding-footer/style.module.css
@@ -0,0 +1,45 @@
+/* Progressive blur scrim: the backdrop blur fades out toward the top via
+ the mask, and the background gradient keeps the action bar legible over
+ content scrolling underneath — mirrors the desktop renderer's stepper. */
+.scrim {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 88px;
+ pointer-events: none;
+ z-index: 5;
+ /* Own snapshot group so the router's view transition doesn't fade/rise
+ the fixed bar along with the page content. */
+ view-transition-name: onboarding-footer-scrim;
+ background: linear-gradient(
+ to top,
+ var(--wpds-color-bg-surface-neutral, #fff) 10%,
+ color-mix(in srgb, var(--wpds-color-bg-surface-neutral, #fff) 80%, transparent) 55%,
+ transparent
+ );
+ backdrop-filter: blur(10px);
+ -webkit-mask-image: linear-gradient(to top, black 45%, transparent);
+ mask-image: linear-gradient(to top, black 45%, transparent);
+}
+
+.actions {
+ position: fixed;
+ bottom: 20px;
+ left: 20px;
+ right: 20px;
+ z-index: 6;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 12px;
+ /* Own snapshot group — see .scrim. The persistent Back button reads as
+ stationary chrome across steps instead of animating with the page. */
+ view-transition-name: onboarding-footer-actions;
+}
+
+/* The first action is always Back/Cancel — pin it bottom-left and keep the
+ primary action(s) bottom-right. */
+.actions > :first-child {
+ margin-right: auto;
+}
diff --git a/apps/ui/src/components/onboarding-layout/index.tsx b/apps/ui/src/components/onboarding-layout/index.tsx
index a8928ad396..6f45e651c0 100644
--- a/apps/ui/src/components/onboarding-layout/index.tsx
+++ b/apps/ui/src/components/onboarding-layout/index.tsx
@@ -15,9 +15,10 @@ interface OnboardingLayoutProps {
/**
* Content width variant. Defaults to a narrow column (`'default'`) sized
* for forms and short cards; `'wide'` is used by pages that host grids of
- * content (e.g. the blueprint selector).
+ * content (e.g. the blueprint selector); `'full'` is used by richer picker
+ * pages that need room for responsive card grids.
*/
- width?: 'default' | 'wide';
+ width?: 'default' | 'wide' | 'full';
}
export function OnboardingLayout( {
@@ -38,7 +39,11 @@ export function OnboardingLayout( {
onClick={ onClose }
/>
) }
-
+
{ children }
diff --git a/apps/ui/src/components/onboarding-layout/style.module.css b/apps/ui/src/components/onboarding-layout/style.module.css
index 62a6679ca6..4381099f0a 100644
--- a/apps/ui/src/components/onboarding-layout/style.module.css
+++ b/apps/ui/src/components/onboarding-layout/style.module.css
@@ -15,6 +15,10 @@
max-width: 820px;
}
+.contentFull {
+ max-width: min(1180px, 100vw);
+}
+
.close {
position: absolute;
top: 16px;
diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts
index f4431f4ee4..07f0cea8c7 100644
--- a/apps/ui/src/data/core/connectors/ipc/index.ts
+++ b/apps/ui/src/data/core/connectors/ipc/index.ts
@@ -5,6 +5,7 @@ import type {
AiSessionSummary,
AiSessionPlacementUpdatedEvent,
AuthUser,
+ AvailableSitePath,
ColorScheme,
Connector,
DeskConfig,
@@ -192,8 +193,8 @@ export function createIpcConnector(): Connector {
};
},
- async authenticate(): Promise< void > {
- await ipcApi.authenticate( false );
+ async authenticate( signup = false ): Promise< void > {
+ await ipcApi.authenticate( signup );
},
async logout(): Promise< void > {
@@ -221,6 +222,7 @@ export function createIpcConnector(): Connector {
adminPassword,
adminEmail,
blueprint,
+ skipStart,
} = params;
return ( await ipcApi.createSite( path, {
siteName: name,
@@ -232,6 +234,7 @@ export function createIpcConnector(): Connector {
adminPassword,
adminEmail,
blueprint,
+ noStart: skipStart,
} ) ) as SiteDetails;
},
@@ -258,6 +261,16 @@ export function createIpcConnector(): Connector {
return ( await ipcApi.generateSiteNameFromList( usedSites ) ) as string;
},
+ async findAvailableSitePath( baseName ): Promise< AvailableSitePath > {
+ // The main process resolves the numbered-name collision search in a
+ // single call (checking both existing site names and non-empty site
+ // folders) — same helper `copySite` uses above.
+ const sites = ( await ipcApi.getSiteDetails() ) as SiteDetails[];
+ const name = ( await ipcApi.generateNumberedNameFromList( baseName, sites ) ) as string;
+ const { path } = ( await ipcApi.generateProposedSitePath( name ) ) as { path: string };
+ return { name, path };
+ },
+
async generateProposedSitePath( siteName ): Promise< ProposedSitePath > {
const response = ( await ipcApi.generateProposedSitePath( siteName ) ) as {
path: string;
@@ -466,6 +479,10 @@ export function createIpcConnector(): Connector {
return ( await ipcApi.fetchSyncableWpcomSites() ) as SyncSite[];
},
+ async fetchSyncableWpcomSitesPage( options ) {
+ return await ipcApi.fetchSyncableWpcomSitesPage( options );
+ },
+
async connectWpcomSite( localSiteId, site ): Promise< void > {
await ipcApi.connectWpcomSites( [ { sites: [ site ], localSiteId } ] );
},
diff --git a/apps/ui/src/data/core/index.ts b/apps/ui/src/data/core/index.ts
index b0eff4e788..9c6d94aafb 100644
--- a/apps/ui/src/data/core/index.ts
+++ b/apps/ui/src/data/core/index.ts
@@ -4,6 +4,7 @@ export type {
AiModelId,
AiSessionSummary,
AuthUser,
+ AvailableSitePath,
ColorScheme,
Connector,
CreateSiteParams,
@@ -36,6 +37,7 @@ export type {
SupportedLocale,
SupportedTerminal,
SyncSite,
+ SyncableWpcomSitesPage,
UserPreferences,
WritableUserPreferences,
} from './types';
diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts
index 5e171dbe3e..d6a5985e0b 100644
--- a/apps/ui/src/data/core/types.ts
+++ b/apps/ui/src/data/core/types.ts
@@ -91,6 +91,15 @@ export interface AuthUser {
displayName: string;
}
+export interface SyncableWpcomSitesPage {
+ sites: SyncSite[];
+ total: number;
+ page: number;
+ perPage: number;
+ hasMore: boolean;
+ nextPage: number | null;
+}
+
export interface Connector {
/**
* Optional hook for connector-specific setup that must run after the
@@ -102,7 +111,9 @@ export interface Connector {
requiresAuth: boolean;
isAuthenticated(): Promise< boolean >;
getAuthUser(): Promise< AuthUser | null >;
- authenticate(): Promise< void >;
+ // Starts the WordPress.com OAuth flow in the browser. Pass `signup` to
+ // land on account creation instead of login.
+ authenticate( signup?: boolean ): Promise< void >;
logout(): Promise< void >;
onAuthStateChanged?( listener: () => void ): () => void;
@@ -143,6 +154,11 @@ export interface Connector {
// and domain lookups).
generateProposedSitePath( siteName: string ): Promise< ProposedSitePath >;
generateProposedSiteName( usedSites: SiteDetails[] ): Promise< string >;
+ // Resolves a base name to one that doesn't collide with an existing site
+ // name or a non-empty site folder ("My Site", "My Site 2", ...), returning
+ // it with its proposed directory. The collision search runs in the main
+ // process so callers pay a constant number of IPC round-trips.
+ findAvailableSitePath( baseName: string ): Promise< AvailableSitePath >;
selectSiteFolder( defaultPath: string ): Promise< SelectedSiteFolder | null >;
comparePaths( path1: string, path2: string ): Promise< boolean >;
getAllCustomDomains(): Promise< string[] >;
@@ -188,6 +204,13 @@ export interface Connector {
// of which (if any) local site they're already connected to. The publish
// picker filters this list to sites that aren't connected anywhere yet.
fetchSyncableWpcomSites(): Promise< SyncSite[] >;
+ // Paged variant used by picker flows so large accounts don't fetch every
+ // site and start every thumbnail preview at once.
+ fetchSyncableWpcomSitesPage( options: {
+ page?: number;
+ perPage?: number;
+ search?: string;
+ } ): Promise< SyncableWpcomSitesPage >;
// Persists a new local↔live connection so the dropdown picks it up via
// `getConnectedWpcomSites`. Safe to call with the minimal `SyncSite` we
// receive from a sync-connect-site deep link — later fetches backfill the
@@ -352,6 +375,10 @@ export interface CreateSiteParams {
adminUsername?: string;
adminPassword?: string;
adminEmail?: string;
+ // Skips starting the site server after creation. Used by flows that
+ // immediately overwrite the fresh install (pulling a connected
+ // WordPress.com site), where the sync handler restarts the server itself.
+ skipStart?: boolean;
// Optional blueprint payload. When present, `blueprint` is the parsed
// blueprint JSON; `slug` is set for featured blueprints (used for stats);
// `filePath` points at the extracted `blueprint.json` inside a ZIP bundle
@@ -377,6 +404,11 @@ export interface ProposedSitePath {
isNameTooLong?: boolean;
}
+export interface AvailableSitePath {
+ name: string;
+ path: string;
+}
+
export interface SelectedSiteFolder {
path: string;
name: string;
diff --git a/apps/ui/src/data/queries/use-create-site-helpers.ts b/apps/ui/src/data/queries/use-create-site-helpers.ts
index 2e0f0a6c6e..6c89ad2aa3 100644
--- a/apps/ui/src/data/queries/use-create-site-helpers.ts
+++ b/apps/ui/src/data/queries/use-create-site-helpers.ts
@@ -2,7 +2,12 @@ import { useQuery } from '@tanstack/react-query';
import { __ } from '@wordpress/i18n';
import { useCallback } from 'react';
import { useConnector } from '@/data/core';
-import type { ProposedSitePath, SelectedSiteFolder, SiteDetails } from '@/data/core';
+import type {
+ AvailableSitePath,
+ ProposedSitePath,
+ SelectedSiteFolder,
+ SiteDetails,
+} from '@/data/core';
const CUSTOM_DOMAINS_QUERY_KEY = [ 'customDomains' ] as const;
const PROPOSED_SITE_NAME_QUERY_KEY = [ 'proposedSiteName' ] as const;
@@ -34,6 +39,22 @@ export function useProposedSiteName( sites: SiteDetails[] | undefined ) {
} );
}
+/**
+ * Finds a site name that doesn't collide with an existing site or a
+ * non-empty site folder by appending an incrementing suffix ("My Site",
+ * "My Site 2", ...), and returns it together with its proposed directory.
+ * Used when a flow seeds the name from an external source rather than user
+ * input. The search runs in the main process in one round-trip.
+ */
+export function useFindAvailableSiteName() {
+ const connector = useConnector();
+ return useCallback(
+ ( baseName: string ): Promise< AvailableSitePath > =>
+ connector.findAvailableSitePath( baseName ),
+ [ connector ]
+ );
+}
+
/**
* Returns two imperative helpers the create form uses to validate paths:
*
diff --git a/apps/ui/src/data/queries/use-wpcom-sites.test.tsx b/apps/ui/src/data/queries/use-wpcom-sites.test.tsx
new file mode 100644
index 0000000000..ff763dbc36
--- /dev/null
+++ b/apps/ui/src/data/queries/use-wpcom-sites.test.tsx
@@ -0,0 +1,120 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { ConnectorProvider } from '@/data/core';
+import { useSyncableWpcomSitesPages } from './use-wpcom-sites';
+import type { Connector, SyncableWpcomSitesPage, SyncSite } from '@/data/core';
+import type { ReactNode } from 'react';
+
+const PAGE_SIZE = 12;
+
+function makeSite( id: number ): SyncSite {
+ return {
+ id,
+ localSiteId: '',
+ name: `Site ${ id }`,
+ url: `https://site-${ id }.example.com`,
+ isStaging: false,
+ isPressable: false,
+ environmentType: null,
+ syncSupport: 'syncable',
+ lastPullTimestamp: null,
+ lastPushTimestamp: null,
+ };
+}
+
+function makePage( sites: SyncSite[], nextPage: number | null, page = 1 ): SyncableWpcomSitesPage {
+ return {
+ sites,
+ total: 14,
+ page,
+ perPage: PAGE_SIZE,
+ hasMore: nextPage !== null,
+ nextPage,
+ };
+}
+
+function createWrapper( connector: Partial< Connector > ) {
+ const queryClient = new QueryClient( {
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ } );
+
+ return function Wrapper( { children }: { children: ReactNode } ) {
+ return (
+
+ { children }
+
+ );
+ };
+}
+
+describe( 'useSyncableWpcomSitesPages', () => {
+ it( 'loads the first page of syncable WordPress.com sites', async () => {
+ const page = makePage( [ makeSite( 1 ) ], 2 );
+ const fetchSyncableWpcomSitesPage = vi.fn().mockResolvedValue( page );
+
+ const { result } = renderHook( () => useSyncableWpcomSitesPages(), {
+ wrapper: createWrapper( { fetchSyncableWpcomSitesPage } ),
+ } );
+
+ await waitFor( () => expect( result.current.data ).toBeDefined() );
+
+ expect( fetchSyncableWpcomSitesPage ).toHaveBeenCalledWith( {
+ page: 1,
+ perPage: PAGE_SIZE,
+ search: undefined,
+ } );
+ expect( result.current.data?.pages ).toEqual( [ page ] );
+ expect( result.current.hasNextPage ).toBe( true );
+ } );
+
+ it( 'loads the next page when requested', async () => {
+ const firstPage = makePage( [ makeSite( 1 ) ], 2 );
+ const secondPage = makePage( [ makeSite( 2 ) ], null, 2 );
+ const fetchSyncableWpcomSitesPage = vi
+ .fn()
+ .mockResolvedValueOnce( firstPage )
+ .mockResolvedValueOnce( secondPage );
+
+ const { result } = renderHook( () => useSyncableWpcomSitesPages(), {
+ wrapper: createWrapper( { fetchSyncableWpcomSitesPage } ),
+ } );
+
+ await waitFor( () => expect( result.current.data?.pages ).toEqual( [ firstPage ] ) );
+
+ await act( async () => {
+ await result.current.fetchNextPage();
+ } );
+
+ expect( fetchSyncableWpcomSitesPage ).toHaveBeenLastCalledWith( {
+ page: 2,
+ perPage: PAGE_SIZE,
+ search: undefined,
+ } );
+ await waitFor( () =>
+ expect( result.current.data?.pages ).toEqual( [ firstPage, secondPage ] )
+ );
+ expect( result.current.hasNextPage ).toBe( false );
+ } );
+
+ it( 'passes trimmed search terms through to the paged fetch', async () => {
+ const page = makePage( [ makeSite( 7 ) ], null );
+ const fetchSyncableWpcomSitesPage = vi.fn().mockResolvedValue( page );
+
+ renderHook( () => useSyncableWpcomSitesPages( { search: ' example ' } ), {
+ wrapper: createWrapper( { fetchSyncableWpcomSitesPage } ),
+ } );
+
+ await waitFor( () =>
+ expect( fetchSyncableWpcomSitesPage ).toHaveBeenCalledWith( {
+ page: 1,
+ perPage: PAGE_SIZE,
+ search: 'example',
+ } )
+ );
+ } );
+} );
diff --git a/apps/ui/src/data/queries/use-wpcom-sites.ts b/apps/ui/src/data/queries/use-wpcom-sites.ts
index 9b7f717bf0..17a96485c4 100644
--- a/apps/ui/src/data/queries/use-wpcom-sites.ts
+++ b/apps/ui/src/data/queries/use-wpcom-sites.ts
@@ -1,9 +1,10 @@
-import { useQuery } from '@tanstack/react-query';
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useConnector } from '@/data/core';
import type { SyncSite } from '@/data/core';
const SYNCABLE_WPCOM_SITES_QUERY_KEY = [ 'syncable-wpcom-sites' ] as const;
+const SYNCABLE_WPCOM_SITES_PAGE_SIZE = 12;
const ALL_CONNECTED_WPCOM_SITES_QUERY_KEY = [ 'all-connected-wpcom-sites' ] as const;
export function useSyncableWpcomSites( options: { enabled?: boolean } = {} ) {
@@ -19,6 +20,24 @@ export function useSyncableWpcomSites( options: { enabled?: boolean } = {} ) {
} );
}
+export function useSyncableWpcomSitesPages( options: { enabled?: boolean; search?: string } = {} ) {
+ const connector = useConnector();
+ const search = options.search?.trim() ?? '';
+ return useInfiniteQuery( {
+ queryKey: [ ...SYNCABLE_WPCOM_SITES_QUERY_KEY, 'pages', search ],
+ queryFn: ( { pageParam } ) =>
+ connector.fetchSyncableWpcomSitesPage( {
+ page: pageParam,
+ perPage: SYNCABLE_WPCOM_SITES_PAGE_SIZE,
+ search: search || undefined,
+ } ),
+ initialPageParam: 1,
+ getNextPageParam: ( lastPage ) => lastPage.nextPage ?? undefined,
+ enabled: options.enabled ?? true,
+ staleTime: 5 * 60 * 1000,
+ } );
+}
+
// Mirrors `useConnectedWpcomSites` but returns connections for every local
// site — used to filter out WordPress.com sites that are already attached to
// another Studio site when picking a publish target.
diff --git a/apps/ui/src/hooks/use-grid-arrow-navigation.ts b/apps/ui/src/hooks/use-grid-arrow-navigation.ts
new file mode 100644
index 0000000000..0910f23050
--- /dev/null
+++ b/apps/ui/src/hooks/use-grid-arrow-navigation.ts
@@ -0,0 +1,73 @@
+import { useCallback } from 'react';
+
+const NAV_KEYS = [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End' ];
+
+/**
+ * Arrow-key navigation between the items of a card grid (or row). Attach the
+ * returned handler to the container's `onKeyDown` and mark each navigable
+ * control with `data-arrow-nav-item`.
+ *
+ * Left/Right move linearly (direction-aware for RTL); Up/Down move by visual
+ * row using the container's resolved CSS-grid column count (flex rows resolve
+ * to one "column", which degrades to linear movement); Home/End jump to the
+ * ends. Tab order is left untouched — arrows are an enhancement on top of it,
+ * so overlay controls inside a card wrapper (preview buttons, CTAs) keep
+ * their regular tab stops.
+ */
+export function useGridArrowNavigation() {
+ return useCallback( ( event: React.KeyboardEvent< HTMLElement > ) => {
+ if ( ! NAV_KEYS.includes( event.key ) ) {
+ return;
+ }
+ const target = event.target as HTMLElement;
+ const origin = target.closest< HTMLElement >( '[data-arrow-nav-item]' );
+ if ( ! origin ) {
+ return;
+ }
+ const container = event.currentTarget;
+ const items = Array.from(
+ container.querySelectorAll< HTMLElement >( '[data-arrow-nav-item]' )
+ );
+ const index = items.indexOf( origin );
+ if ( index === -1 ) {
+ return;
+ }
+
+ const style = getComputedStyle( container );
+ const columns =
+ style.display === 'grid' && style.gridTemplateColumns !== 'none'
+ ? style.gridTemplateColumns.split( ' ' ).length
+ : 1;
+ const isRtl = style.direction === 'rtl';
+ const forward = isRtl ? 'ArrowLeft' : 'ArrowRight';
+ const backward = isRtl ? 'ArrowRight' : 'ArrowLeft';
+
+ let next = -1;
+ switch ( event.key ) {
+ case forward:
+ next = index + 1;
+ break;
+ case backward:
+ next = index - 1;
+ break;
+ case 'ArrowDown':
+ next = index + columns;
+ break;
+ case 'ArrowUp':
+ next = index - columns;
+ break;
+ case 'Home':
+ next = 0;
+ break;
+ case 'End':
+ next = items.length - 1;
+ break;
+ }
+
+ if ( next < 0 || next >= items.length || next === index ) {
+ return;
+ }
+ event.preventDefault();
+ items[ next ].focus();
+ }, [] );
+}
diff --git a/apps/ui/src/hooks/use-offline.ts b/apps/ui/src/hooks/use-offline.ts
new file mode 100644
index 0000000000..a5374a7a56
--- /dev/null
+++ b/apps/ui/src/hooks/use-offline.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Tracks the browser's online/offline state. Mirrors the desktop renderer's
+ * hook of the same name — used to disable network-dependent flows like
+ * connecting a WordPress.com site.
+ */
+export function useOffline(): boolean {
+ const [ isOffline, setIsOffline ] = useState( ! navigator.onLine );
+
+ useEffect( () => {
+ const handleOnline = () => setIsOffline( false );
+ const handleOffline = () => setIsOffline( true );
+ window.addEventListener( 'online', handleOnline );
+ window.addEventListener( 'offline', handleOffline );
+ return () => {
+ window.removeEventListener( 'online', handleOnline );
+ window.removeEventListener( 'offline', handleOffline );
+ };
+ }, [] );
+
+ return isOffline;
+}
diff --git a/apps/ui/src/lib/docs-links.ts b/apps/ui/src/lib/docs-links.ts
index 67ae38523d..b1b2099611 100644
--- a/apps/ui/src/lib/docs-links.ts
+++ b/apps/ui/src/lib/docs-links.ts
@@ -15,6 +15,10 @@ const DOCS_LINKS = {
docsSslInStudio: {
en: 'https://developer.wordpress.com/docs/developer-tools/studio/ssl-in-studio/',
},
+ docsSyncSupportedSites: {
+ en: 'https://developer.wordpress.com/docs/developer-tools/studio/sync/#supported-sites',
+ es: 'https://developer.wordpress.com/es/docs/herramientas-para-desarrolladores/studio/sync/#sitios-compatibles',
+ },
} as const satisfies Record< string, TranslatedLink >;
export type DocsLinkKey = keyof typeof DOCS_LINKS;
diff --git a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx
index 0db88f49b7..1bc343550a 100644
--- a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx
+++ b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx
@@ -14,6 +14,7 @@ function OnboardingShell() {
// "configure" step reuses the shared site form and should match the
// narrow "/onboarding/create" width.
const matches = useMatches();
+ const isFull = matches.some( ( match ) => match.pathname === '/onboarding/connect' );
const isWide = matches.some( ( match ) => {
if ( match.pathname === '/onboarding' ) return true;
if ( match.pathname !== '/onboarding/blueprint' ) return false;
@@ -23,7 +24,7 @@ function OnboardingShell() {
return (
void navigate( { to: '/' } ) : undefined }
- width={ isWide ? 'wide' : 'default' }
+ width={ isFull ? 'full' : isWide ? 'wide' : 'default' }
>
diff --git a/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css b/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css
index 3bf06f28c5..8532783c9a 100644
--- a/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css
+++ b/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css
@@ -3,6 +3,10 @@
text-align: left;
}
+.pageSpacious {
+ padding-top: 64px;
+}
+
/* Typography comes from the universal `h1` rule in index.css — this class
only layers on the layout spacing above the subtitle. */
.title {
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx
new file mode 100644
index 0000000000..e53fe13582
--- /dev/null
+++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx
@@ -0,0 +1,643 @@
+import { getEnvironmentLabel, getSiteEnvironment } from '@studio/common/lib/sync/environment-utils';
+import { getMshotUrl } from '@studio/common/lib/sync/mshots';
+import { createRoute, useNavigate } from '@tanstack/react-router';
+import { speak } from '@wordpress/a11y';
+import { Spinner } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+import { chevronLeft, check, external, search, wordpress } from '@wordpress/icons';
+import { Button, Icon, Input, InputLayout } from '@wordpress/ui';
+import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
+import { BusyOverlay } from '@/components/busy-overlay';
+import { OnboardingFooter } from '@/components/onboarding-footer';
+import { useConnector } from '@/data/core';
+import { useAuthUser } from '@/data/queries/use-auth-user';
+import { useFindAvailableSiteName } from '@/data/queries/use-create-site-helpers';
+import { useCreateSite, useDeleteSite } from '@/data/queries/use-sites';
+import { usePullSiteFromLive } from '@/data/queries/use-sync-site';
+import { useUserLocale } from '@/data/queries/use-user-locale';
+import { useSyncableWpcomSitesPages } from '@/data/queries/use-wpcom-sites';
+import { useGridArrowNavigation } from '@/hooks/use-grid-arrow-navigation';
+import { useOffline } from '@/hooks/use-offline';
+import { getLocalizedLink } from '@/lib/docs-links';
+import { onboardingLayoutRoute } from '../layout-onboarding';
+import sharedStyles from '../layout-onboarding/style.module.css';
+import styles from './style.module.css';
+import type { SyncSite } from '@/data/core';
+
+const SEARCH_VISIBILITY_THRESHOLD = 5;
+
+// Same wordpress.com new-site flow the desktop renderer's Create button
+// opens (see generate-checkout-url.ts).
+const CREATE_WPCOM_SITE_URL =
+ 'https://wordpress.com/setup/new-hosted-site?ref=studio§ion=studio-sync&showDomainStep=true';
+
+// Labels for sites the user can see but not pick; the needs-upgrade and
+// needs-transfer groups get overlay CTAs instead.
+function getSyncStatusLabel( site: SyncSite ): string | null {
+ switch ( site.syncSupport ) {
+ case 'already-connected':
+ return __( 'Already connected' );
+ case 'missing-permissions':
+ return __( 'Missing permissions' );
+ case 'deleted':
+ return __( 'Deleted' );
+ case 'unsupported':
+ return __( 'Unsupported' );
+ default:
+ return null;
+ }
+}
+
+interface SiteSection {
+ key: string;
+ title?: string;
+ description?: string;
+ sites: SyncSite[];
+}
+
+// Groups sites the way the desktop renderer's picker does: syncable sites
+// lead (no heading), followed by explained groups for everything else.
+function groupSites( sites: SyncSite[] ): SiteSection[] {
+ const syncable = sites.filter( ( s ) => s.syncSupport === 'syncable' );
+ const alreadyConnected = sites.filter( ( s ) => s.syncSupport === 'already-connected' );
+ const needsTransfer = sites.filter( ( s ) => s.syncSupport === 'needs-transfer' );
+ const needsUpgrade = sites.filter( ( s ) => s.syncSupport === 'needs-upgrade' );
+ const other = sites.filter(
+ ( s ) =>
+ s.syncSupport === 'unsupported' ||
+ s.syncSupport === 'missing-permissions' ||
+ s.syncSupport === 'deleted'
+ );
+
+ const sections: SiteSection[] = [];
+ if ( syncable.length > 0 ) {
+ sections.push( { key: 'syncable', sites: syncable } );
+ }
+ if ( alreadyConnected.length > 0 ) {
+ sections.push( {
+ key: 'already-connected',
+ title: __( 'Already connected' ),
+ description: __( 'These sites are already linked to a local site.' ),
+ sites: alreadyConnected,
+ } );
+ }
+ if ( needsTransfer.length > 0 ) {
+ sections.push( {
+ key: 'needs-transfer',
+ title: __( 'Enable hosting features first' ),
+ description: __(
+ 'These sites need hosting features turned on before they can sync. You can do this from WordPress.com.'
+ ),
+ sites: needsTransfer,
+ } );
+ }
+ if ( needsUpgrade.length > 0 ) {
+ sections.push( {
+ key: 'needs-upgrade',
+ title: __( 'Upgrade your plan to sync' ),
+ description: __(
+ 'Syncing requires a Business plan or higher. Upgrade on WordPress.com to get started.'
+ ),
+ sites: needsUpgrade,
+ } );
+ }
+ if ( other.length > 0 ) {
+ sections.push( {
+ key: 'other',
+ title: __( 'Not available for sync' ),
+ description: __(
+ "These sites can't be synced due to missing permissions or other limitations."
+ ),
+ sites: other,
+ } );
+ }
+ return sections;
+}
+
+function SignedOutView() {
+ const connector = useConnector();
+ const isOffline = useOffline();
+
+ const benefits = [
+ __( 'Work on your site locally.' ),
+ __( 'Sync content, themes, and plugins.' ),
+ __( 'Supports staging and production sites.' ),
+ ];
+
+ return (
+
+
+ { benefits.map( ( benefit ) => (
+
+
+ { benefit }
+
+ ) ) }
+
+
+
+
+ { __( 'New to WordPress.com?' ) }{ ' ' }
+
+
+ { isOffline && (
+
{ __( "You're currently offline." ) }
+ ) }
+
+
+ );
+}
+
+// Centered call to action on a non-syncable site's thumbnail — "Enable" for
+// sites that need hosting features, "Upgrade plan" for free-plan sites.
+// Rendered as a sibling of the (inert) card button so the markup stays valid.
+function ThumbnailCta( { site }: { site: SyncSite } ) {
+ const connector = useConnector();
+
+ if ( site.syncSupport === 'needs-upgrade' ) {
+ return (
+
+ { /* Connecting creates the local site and persists the connection;
+ shield the window so stray clicks can't interrupt mid-flight. */ }
+
+
+ { isSignedIn && isSingleSite ? __( 'Connect your site' ) : __( 'Connect a site' ) }
+
+
+ { ! isSignedIn && __( 'Connect your WordPress.com account to access your sites.' ) }
+ { isSignedIn &&
+ ( isSingleSite
+ ? __( 'Ready to bring into your Studio.' )
+ : __( 'Select a WordPress.com or Pressable site to bring into your Studio.' ) ) }
+
+
+ { ! isSignedIn && ! isAuthLoading && }
+
+ { isSignedIn && (
+ <>
+ { /* The helper links read "Refreshing…" during the initial
+ load; hide the whole row until the list exists and let
+ the loading state below carry the message. */ }
+ { ! isSingleSite && ! isLoadingFirstPage && (
+
+ ) }
+ { /* Only the lead section grows its cards to fill the
+ row — and only when not searching; secondary groups
+ and filtered results stay at the compact size. */ }
+
+ { section.sites.map( ( site ) => (
+
+ ) ) }
+
+
+ ) ) }
+ { hasNextSitesPage && (
+
+
+ { sprintf(
+ // translators: 1: number of sites shown, 2: total number of sites.
+ __( 'Showing %1$d of %2$d sites.' ),
+ sites.length,
+ totalSites
+ ) }
+