Skip to content

Commit e8933b2

Browse files
authored
Agentic UI: Create blank or Blueprint sites (#4177)
## Related issues <!-- Link a related issue to this PR. If the PR does not immediately resolve the issue, for example, it requires a separate deployment to production, avoid using the "Fixes" keyword and use "Related to" instead. --> - First product increment from #3941 ## How AI was used in this PR <!-- Help reviewers understand what to look for and verify that you have reviewed the code yourself. --> Codex helped compare the original umbrella PR with current trunk, implement the scoped first increment, and add focused coverage. The resulting behavior, cleanup paths, and tests were manually reviewed. The updated visual design still needs human review in both color schemes. ## Proposed Changes <!-- Explain the intent of this PR: - What problem does it solve, or what need does it address? - How does it affect the user (new capability, fix, performance, accessibility, etc.)? - Note any user-visible behavior changes or trade-offs. Focus on the why and the user impact. Avoid listing modified files or describing implementation mechanics — the diff already shows that. --> - Give users one Create flow for blank sites and uploaded JSON or ZIP Blueprints, including full-window drop, replacement, removal, reset of prior untouched suggestions, and Blueprint deep-link handoff. - Preserve user-entered values while applying Blueprint suggestions only to pristine fields, and retain the existing path and WordPress-version reliability behavior. - Keep temporary uploads safe across selection, replacement, cancellation, creation, and failure recovery. - Make creation progress accessible, prevent route interaction and closing while creation is active, restore the flow after failure, and manage focus between onboarding routes. - Simplify onboarding around the currently working Create and Import jobs, redirect the legacy Blueprint route into Create, and remove the unused gallery surface. - Bring the updated onboarding design into the reduced flow with the animated dot-grid backdrop, illustrated job cards, frosted panels, responsive layouts, persistent footer actions, and compact Blueprint picker. ## Testing Instructions <!-- Add as many details as possible to help others reproduce the issue and test the fix. "Before / After" screenshots can also be very helpful when the change is visual. --> 1. Open onboarding and confirm the home screen shows the dot-grid backdrop, illustrated Create and Import cards, and persistent Back action when existing sites are present. Start Create and create a blank site. 2. Select JSON and ZIP Blueprints using the inline picker and full-window drop target. Confirm the selected file can be replaced or removed and that ZIP-relative assets still resolve during creation. 3. Edit the site name or path before selecting a Blueprint. Confirm suggestions fill pristine fields without overwriting dirty fields. 4. Open a Blueprint deep link and confirm it hands the uploaded Blueprint to `/onboarding/create`. 5. During creation, confirm the visible status is announced, route content is inert, Close is disabled, and route headings receive focus. Force a failure and confirm the form recovers. 6. Confirm uploaded and extracted temporary files are cleaned after invalid uploads, stale selections, replacement, removal, success, and failure. 7. Check the Create flow, selected Blueprint state, drop overlay, advanced settings, and progress state in both light and dark color schemes. ## Pre-merge Checklist <!-- Complete applicable items on this checklist **before** merging into trunk. Inapplicable items can be left unchecked. Both the PR author and reviewer are responsible for ensuring the checklist is completed. --> - [x] Have you checked for TypeScript, React or other console errors?
1 parent 48dfe84 commit e8933b2

65 files changed

Lines changed: 3587 additions & 1334 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/local/src/index.ts‎

Lines changed: 24 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { createJsonResponse, fetchSiteRest } from '@studio/common/lib/wordpress-
5050
import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils';
5151
import {
5252
cleanupBlueprintTempDir,
53-
extractBlueprintBundle,
53+
extractBlueprintUpload,
5454
} from '@studio/common/sites/blueprint-extract';
5555
import { buildSiteCreateArgs, type SiteCreateOptions } from '@studio/common/sites/create';
5656
import { buildSiteSetArgs } from '@studio/common/sites/edit';
@@ -535,30 +535,36 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
535535
const siteId = crypto.randomUUID();
536536
// Build the create args with the same shared helper the desktop uses, so
537537
// Blueprints (and --wp dev→nightly, etc.) are handled identically.
538-
const { args, cleanup } = buildSiteCreateArgs( {
539-
path: body.path,
540-
name: body.name,
541-
siteId,
542-
wpVersion: body.wpVersion,
543-
phpVersion: body.phpVersion,
544-
customDomain: body.customDomain,
545-
enableHttps: body.enableHttps,
546-
adminUsername: body.adminUsername,
547-
adminPassword: body.adminPassword,
548-
adminEmail: body.adminEmail,
549-
blueprint: body.blueprint?.blueprint,
550-
originalBlueprintPath: body.blueprint?.filePath,
551-
} );
552-
538+
let cleanupCreateArgs: () => void = () => undefined;
553539
try {
540+
const { args, cleanup } = buildSiteCreateArgs( {
541+
path: body.path,
542+
name: body.name,
543+
siteId,
544+
wpVersion: body.wpVersion,
545+
phpVersion: body.phpVersion,
546+
customDomain: body.customDomain,
547+
enableHttps: body.enableHttps,
548+
adminUsername: body.adminUsername,
549+
adminPassword: body.adminPassword,
550+
adminEmail: body.adminEmail,
551+
blueprint: body.blueprint?.blueprint,
552+
originalBlueprintPath: body.blueprint?.filePath,
553+
} );
554+
cleanupCreateArgs = cleanup;
554555
await new Promise< void >( ( resolve, reject ) => {
555556
const [ emitter ] = execute( args, { output: 'capture' } );
556557
emitter.on( 'success', () => resolve() );
557558
emitter.on( 'failure', ( { error } ) => reject( error ) );
558559
emitter.on( 'error', ( { error } ) => reject( error ) );
559560
} );
560561
} finally {
561-
cleanup();
562+
cleanupCreateArgs();
563+
if ( body.blueprint?.filePath ) {
564+
await cleanupBlueprintTempDir( path.dirname( body.blueprint.filePath ) ).catch(
565+
() => undefined
566+
);
567+
}
562568
}
563569

564570
const created = ( await listSites( execute ) ).find( ( s ) => s.id === siteId );
@@ -790,24 +796,10 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
790796
} )
791797
);
792798

793-
// Extract an uploaded Blueprint ZIP (the path comes from /uploads) and return
794-
// its parsed blueprint.json — the shared extractor the desktop uses.
795799
api.post(
796800
'/blueprints/extract',
797801
asyncHandler( async ( req: Request, res: Response ) => {
798-
const zipFilePath = req.body?.zipFilePath;
799-
if ( typeof zipFilePath !== 'string' || ! zipFilePath ) {
800-
res.status( 400 ).json( { error: 'Missing zipFilePath' } );
801-
return;
802-
}
803-
// The zip is always an upload under the OS temp dir (via /uploads);
804-
// reject anything else so this can't be used to read arbitrary files.
805-
const resolvedZip = path.resolve( zipFilePath );
806-
if ( ! ( resolvedZip + path.sep ).startsWith( path.resolve( os.tmpdir() ) + path.sep ) ) {
807-
res.status( 400 ).json( { error: 'zipFilePath must be an uploaded file' } );
808-
return;
809-
}
810-
res.json( await extractBlueprintBundle( resolvedZip ) );
802+
res.json( await extractBlueprintUpload( req ) );
811803
} )
812804
);
813805

‎apps/studio/src/hooks/use-add-site.ts‎

Lines changed: 10 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION } from '@studio/common/c
33
import { updateBlueprintWithFormValues } from '@studio/common/lib/blueprint-settings';
44
import { generateCustomDomainFromSiteName } from '@studio/common/lib/domains';
55
import { type SiteFileAccess } from '@studio/common/lib/site-file-access';
6+
import {
7+
validateProposedSitePath,
8+
validateSelectedSitePath,
9+
type PathValidationResult,
10+
} from '@studio/common/lib/site-path-validation';
611
import { type SiteRuntime } from '@studio/common/lib/site-runtime';
712
import { SupportedPHPVersion } from '@studio/common/types/php-versions';
813
import { useI18n } from '@wordpress/react-i18n';
@@ -38,16 +43,7 @@ export interface CreateSiteFormValues {
3843
adminEmail?: string;
3944
}
4045

41-
/**
42-
* Result from path selection or site name change validation
43-
*/
44-
export interface PathValidationResult {
45-
path: string;
46-
name?: string;
47-
isEmpty: boolean;
48-
isWordPress: boolean;
49-
error?: string;
50-
}
46+
export type { PathValidationResult } from '@studio/common/lib/site-path-validation';
5147

5248
export function useAddSite() {
5349
const { __ } = useI18n();
@@ -147,38 +143,7 @@ export function useAddSite() {
147143
return null;
148144
}
149145

150-
const { path, name, isEmpty, isWordPress } = response;
151-
152-
if ( await checkPathExists( path ) ) {
153-
return {
154-
path,
155-
name: name ?? undefined,
156-
isEmpty,
157-
isWordPress,
158-
error: __(
159-
'The directory is already associated with another Studio site. Please choose a different custom local path.'
160-
),
161-
};
162-
}
163-
164-
if ( ! isEmpty && ! isWordPress ) {
165-
return {
166-
path,
167-
name: name ?? undefined,
168-
isEmpty,
169-
isWordPress,
170-
error: __(
171-
'This directory is not empty. Please select an empty directory or an existing WordPress folder.'
172-
),
173-
};
174-
}
175-
176-
return {
177-
path,
178-
name: name ?? undefined,
179-
isEmpty,
180-
isWordPress,
181-
};
146+
return validateSelectedSitePath( response, await checkPathExists( response.path ) );
182147
},
183148
[ __, checkPathExists ]
184149
);
@@ -188,43 +153,10 @@ export function useAddSite() {
188153
*/
189154
const generateProposedPath = useCallback(
190155
async ( siteName: string ): Promise< PathValidationResult > => {
191-
const { path, isEmpty, isWordPress, isNameTooLong } =
192-
await getIpcApi().generateProposedSitePath( siteName );
193-
194-
if ( isNameTooLong ) {
195-
return {
196-
path,
197-
isEmpty,
198-
isWordPress,
199-
error: __( 'The site name is too long. Please choose a shorter site name.' ),
200-
};
201-
}
202-
203-
if ( await checkPathExists( path ) ) {
204-
return {
205-
path,
206-
isEmpty,
207-
isWordPress,
208-
error: __(
209-
'The directory is already associated with another Studio site. Please choose a different site name or a custom local path.'
210-
),
211-
};
212-
}
213-
214-
if ( ! isEmpty && ! isWordPress ) {
215-
return {
216-
path,
217-
isEmpty,
218-
isWordPress,
219-
error: __(
220-
'This directory is not empty. Please select an empty directory or an existing WordPress folder.'
221-
),
222-
};
223-
}
224-
225-
return { path, isEmpty, isWordPress };
156+
const result = await getIpcApi().generateProposedSitePath( siteName );
157+
return validateProposedSitePath( result, await checkPathExists( result.path ) );
226158
},
227-
[ __, checkPathExists ]
159+
[ checkPathExists ]
228160
);
229161

230162
/**

‎apps/studio/src/ipc-handlers.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2269,8 +2269,12 @@ export async function readBlueprintFile(
22692269
throw new Error( 'Blueprint file path must be within the allowed directory' );
22702270
}
22712271

2272-
const fileContents = await fsPromises.readFile( resolvedPath, 'utf-8' );
2273-
return JSON.parse( fileContents );
2272+
try {
2273+
const fileContents = await fsPromises.readFile( resolvedPath, 'utf-8' );
2274+
return JSON.parse( fileContents );
2275+
} finally {
2276+
await fsPromises.rm( resolvedPath, { force: true } );
2277+
}
22742278
}
22752279

22762280
export async function extractBlueprintBundle(

‎apps/studio/src/modules/add-site/components/blueprints.tsx‎

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprint-settings';
2-
import { BlueprintValidationWarning } from '@studio/common/lib/blueprint-validation';
1+
import { prepareBlueprint } from '@studio/common/lib/blueprint-selection';
32
import {
43
__experimentalVStack as VStack,
54
__experimentalHStack as HStack,
@@ -20,6 +19,7 @@ import { cx } from 'src/lib/cx';
2019
import { getIpcApi } from 'src/lib/get-ipc-api';
2120
import { useI18nLocale } from 'src/stores';
2221
import { useGetBlueprints } from 'src/stores/wpcom-api';
22+
import type { BlueprintValidationWarning } from '@studio/common/lib/blueprint-validation';
2323

2424
import './blueprints.css';
2525

@@ -193,45 +193,28 @@ export function AddSiteBlueprintSelector( {
193193
blueprint: Blueprint;
194194
warnings: BlueprintValidationWarning[] | undefined;
195195
} | null > => {
196-
const meta = blueprintJson as {
197-
version?: number;
198-
meta?: { title?: string; description?: string };
199-
};
200-
201-
if ( meta.version === 2 ) {
202-
setValidationError(
203-
__( 'Blueprint v2 format is not supported yet. Please use Blueprint v1 format.' )
204-
);
205-
onCleanup?.();
206-
return null;
207-
}
208-
209-
const validation = await getIpcApi().validateBlueprint( blueprintJson );
210-
if ( ! validation.valid ) {
211-
setValidationError( validation.error || __( 'Invalid Blueprint format' ) );
196+
const prepared = await prepareBlueprint( blueprintJson, {
197+
fallbackTitle: fileName.replace( /\.(json|zip)$/i, '' ),
198+
validate: ( candidate ) =>
199+
getIpcApi().validateBlueprint( candidate as Blueprint[ 'blueprint' ] ),
200+
} );
201+
if ( ! prepared.valid ) {
202+
setValidationError( prepared.error );
212203
onCleanup?.();
213204
return null;
214205
}
215206

216-
const warnings =
217-
validation.warnings && validation.warnings.length > 0 ? validation.warnings : undefined;
218-
219-
const baseName = fileName.replace( /\.(json|zip)$/, '' );
220207
const blueprint: Blueprint = {
221208
slug: `file:${ fileName }`,
222-
title: meta.meta?.title || baseName,
223-
excerpt:
224-
meta.meta?.description ||
225-
generateDefaultBlueprintDescription(
226-
blueprintJson as Parameters< typeof generateDefaultBlueprintDescription >[ 0 ]
227-
),
209+
title: prepared.title,
210+
excerpt: prepared.excerpt,
228211
image: '',
229212
playground_url: '',
230-
blueprint: blueprintJson,
213+
blueprint: prepared.blueprint as Blueprint[ 'blueprint' ],
231214
filePath,
232215
};
233216

234-
return { blueprint, warnings };
217+
return { blueprint, warnings: prepared.warnings };
235218
};
236219

237220
const handleFileSelect = async ( event: React.ChangeEvent< HTMLInputElement > ) => {

‎apps/studio/src/modules/add-site/components/upload-blueprint-button.tsx‎

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprint-settings';
1+
import { prepareBlueprint } from '@studio/common/lib/blueprint-selection';
22
import { Button as WpButton } from '@wordpress/components';
33
import { sprintf } from '@wordpress/i18n';
44
import { useI18n } from '@wordpress/react-i18n';
@@ -58,34 +58,23 @@ export function UploadBlueprintButton( {
5858
return;
5959
}
6060

61-
const meta = blueprintJson as {
62-
version?: number;
63-
meta?: { title?: string; description?: string };
64-
};
65-
66-
if ( meta.version === 2 ) {
67-
onError( __( 'Blueprint v2 format is not supported yet. Please use v1.' ) );
68-
return;
69-
}
70-
71-
const validation = await getIpcApi().validateBlueprint( blueprintJson );
72-
if ( ! validation.valid ) {
73-
onError( validation.error || __( 'Invalid Blueprint format' ) );
61+
const prepared = await prepareBlueprint( blueprintJson, {
62+
fallbackTitle: file.name.replace( /\.(json|zip)$/i, '' ),
63+
validate: ( candidate ) =>
64+
getIpcApi().validateBlueprint( candidate as Blueprint[ 'blueprint' ] ),
65+
} );
66+
if ( ! prepared.valid ) {
67+
onError( prepared.error );
7468
return;
7569
}
7670

77-
const baseName = file.name.replace( /\.(json|zip)$/, '' );
7871
const fileBlueprint: Blueprint = {
7972
slug: `file:${ file.name }`,
80-
title: meta.meta?.title || baseName,
81-
excerpt:
82-
meta.meta?.description ||
83-
generateDefaultBlueprintDescription(
84-
blueprintJson as Parameters< typeof generateDefaultBlueprintDescription >[ 0 ]
85-
),
73+
title: prepared.title,
74+
excerpt: prepared.excerpt,
8675
image: '',
8776
playground_url: '',
88-
blueprint: blueprintJson,
77+
blueprint: prepared.blueprint as Blueprint[ 'blueprint' ],
8978
filePath,
9079
};
9180

‎apps/studio/src/modules/add-site/hooks/use-blueprint-deeplink.ts‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprint-settings';
1+
import { getBlueprintDisplayDetails } from '@studio/common/lib/blueprint-selection';
22
import { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation';
33
import { SupportedPHPVersion } from '@studio/common/types/php-versions';
44
import { useCallback } from 'react';
55
import { useIpcListener } from 'src/hooks/use-ipc-listener';
66
import { getIpcApi } from 'src/lib/get-ipc-api';
77
import { Blueprint } from 'src/stores/wpcom-api';
88
import { applyBlueprintFormValues } from '../lib/apply-blueprint-form-values';
9-
10-
type BlueprintMetadata = {
11-
title?: string;
12-
description?: string;
13-
};
9+
import type { BlueprintV1Declaration } from '@wp-playground/blueprints';
1410

1511
interface UseBlueprintDeeplinkOptions {
1612
isAnySiteProcessing: boolean;
@@ -58,13 +54,12 @@ export function useBlueprintDeeplink( options: UseBlueprintDeeplinkOptions ): vo
5854
try {
5955
const blueprintJson = await getIpcApi().readBlueprintFile( blueprintPath );
6056
const fileName = blueprintPath.split( /[/\\]/ ).pop() || 'blueprint.json';
61-
const blueprintMeta = blueprintJson.meta as BlueprintMetadata | undefined;
57+
const details = getBlueprintDisplayDetails( blueprintJson as BlueprintV1Declaration, '' );
6258

6359
const fileBlueprint: Blueprint = {
6460
slug: `file:${ fileName }`,
65-
title: blueprintMeta?.title || '',
66-
excerpt:
67-
blueprintMeta?.description || generateDefaultBlueprintDescription( blueprintJson ),
61+
title: details.title,
62+
excerpt: details.excerpt,
6863
image: '',
6964
playground_url: '',
7065
blueprint: blueprintJson,

0 commit comments

Comments
 (0)