diff --git a/apps/ui/src/components/blueprint-selector/index.tsx b/apps/ui/src/components/blueprint-selector/index.tsx index c6d6f3a93f..b231fb9ff0 100644 --- a/apps/ui/src/components/blueprint-selector/index.tsx +++ b/apps/ui/src/components/blueprint-selector/index.tsx @@ -1,11 +1,17 @@ +import { EMPTY_SITE_PLAYGROUND_URL } from '@studio/common/constants'; +import { + curateBlueprintsForDisplay, + FEATURED_BLUEPRINT_SLUGS, +} from '@studio/common/lib/blueprint-curation'; import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprint-settings'; import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; +import { Spinner } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; -import { external } from '@wordpress/icons'; -import { Button, Icon } from '@wordpress/ui'; -import { useCallback, useState } from 'react'; -import { FileDropzone } from '@/components/file-dropzone'; +import { seen } from '@wordpress/icons'; +import { Button, Icon, Tooltip } from '@wordpress/ui'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useConnector } from '@/data/core'; +import { useGridArrowNavigation } from '@/hooks/use-grid-arrow-navigation'; import styles from './style.module.css'; import type { FeaturedBlueprint } from '@/data/core'; import type { BlueprintV1Declaration } from '@wp-playground/blueprints'; @@ -23,21 +29,159 @@ export interface PickedBlueprint { } interface BlueprintSelectorProps { - featured: FeaturedBlueprint[] | undefined; - isFeaturedLoading: boolean; + blueprints: FeaturedBlueprint[] | undefined; + isLoading: boolean; onPick: ( blueprint: PickedBlueprint ) => void; + // Picking the "Empty site" card — callers route this to the plain + // create-site flow rather than running an empty blueprint. + onPickEmpty: () => void; } const FILE_ACCEPT = 'application/json,.json,application/zip,.zip'; +/** + * Preview (eye) button overlaid on a card's image. Rendered as a sibling of + * the card's pick button (inside `cardMediaOverlay`) rather than nested in + * it, so the markup stays valid — nested interactive elements aren't. + */ +function PreviewOverlay( { url, title }: { url: string; title: string } ) { + const connector = useConnector(); + const label = __( 'Preview in your browser' ); + return ( +
{ __( 'Loading featured blueprints…' ) }
- ) } - { ! isFeaturedLoading && ( ! featured || featured.length === 0 ) && ( -
- { __( 'No featured blueprints available right now.' ) }
+
+ { __( 'Get started quickly with a one of our blueprints, or' ) }{ ' ' }
+
+ { '.' }
+
+ { uploadError }
{ __( 'More blueprints' ) }
+
- { featured.map( ( item ) => (
-
+ { exploreBlueprints.map( ( item ) => (
+
) }
diff --git a/apps/ui/src/components/blueprint-selector/style.module.css b/apps/ui/src/components/blueprint-selector/style.module.css
index 6ee1545b9d..a95aba945b 100644
--- a/apps/ui/src/components/blueprint-selector/style.module.css
+++ b/apps/ui/src/components/blueprint-selector/style.module.css
@@ -16,12 +16,6 @@
margin: 0;
}
-.featuredHint {
- margin: 0;
- color: var(--wpds-color-fg-content-neutral-weak, #666);
- font-size: 0.875rem;
-}
-
.grid {
list-style: none;
margin: 0;
@@ -39,6 +33,98 @@
}
}
+/* The featured row (Empty site + the curated trio) fits four across on the
+ wide layout and folds to a 2x2 grid when the window narrows. */
+.gridFeatured {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+@media (max-width: 880px) {
+ .gridFeatured {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+/* Extra room between the featured row and the explore section so the
+ curated cards stand apart from the long tail. */
+.exploreSection {
+ margin-top: 40px;
+}
+
+/* Centered intro for the blueprints long tail: heading, one-line blurb,
+ then the search + upload controls. */
+.exploreHeader {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ margin-bottom: 24px;
+}
+
+.exploreSubtitle {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak, #666);
+ font-size: 0.875rem;
+}
+
+/* Inline "upload a blueprint" link inside the subtitle — strip the button
+ box so it flows with the sentence. */
+.uploadLink {
+ padding: 0;
+ height: auto;
+ min-height: 0;
+ font-size: inherit;
+}
+
+/* Matches the FileDropzone error pill the upload section used to render. */
+.uploadError {
+ padding: 8px 12px;
+ border-radius: 4px;
+ background: #fce8e8;
+ color: #7a1a1a;
+ font-size: 13px;
+ margin: 0;
+}
+
+.hiddenInput {
+ display: none;
+}
+
+/* Denser columns + tighter type than the featured row, so the open-by-
+ default explore grid reads as secondary to the curated cards. */
+.gridCompact {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 12px;
+}
+
+@media (max-width: 880px) {
+ .gridCompact {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+.gridCompact .cardBody {
+ padding: 10px 12px 12px;
+ gap: 4px;
+}
+
+.gridCompact .cardTitle {
+ font-size: 0.8125rem;
+}
+
+.gridCompact .cardExcerpt {
+ font-size: 0.75rem;
+ -webkit-line-clamp: 2;
+}
+
+.gridStatus {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--wpds-color-fg-content-neutral-weak, #666);
+ font-size: 0.875rem;
+}
+
.cardWrapper {
position: relative;
}
@@ -47,6 +133,7 @@
display: flex;
flex-direction: column;
width: 100%;
+ height: 100%;
padding: 0;
border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd);
border-radius: 8px;
@@ -55,20 +142,71 @@
overflow: hidden;
text-align: left;
color: inherit;
+ transition: border-color 0.15s ease;
}
.card:hover {
- border-color: var(--wpds-color-stroke-interactive-neutral, #bbb);
+ border-color: var(--wpds-color-stroke-interactive-brand, #3858e9);
+}
+
+/* Keyboard focus mirrors the hover affordance, plus an offset ring. */
+.card:focus-visible {
+ outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9);
+ outline-offset: 2px;
+ border-color: var(--wpds-color-stroke-interactive-brand, #3858e9);
+}
+
+/* Anchors the Live Preview pill to the card's media area. Sits outside the
+ pick button (siblings, not nested) so the markup stays valid; the wrapper
+ itself ignores pointer events so card clicks pass through. */
+.cardMediaOverlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ aspect-ratio: 16 / 9;
+ pointer-events: none;
}
.previewButton {
position: absolute;
- top: 8px;
+ bottom: 8px;
right: 8px;
+ pointer-events: auto;
+ display: inline-flex;
+ align-items: center;
gap: 4px;
+ padding: 4px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.9);
+ border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
+ color: #1e1e1e;
+ font-size: 11px;
+ line-height: 1;
+ white-space: nowrap;
+ cursor: pointer;
backdrop-filter: blur(6px);
- border-radius: 4px;
+ /* Hairline dark ring outside the light border so the pill separates
+ from both light and dark imagery — a contrast edge, not elevation. */
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
+ /* Revealed on card hover (the button sits inside the hovered wrapper,
+ so pointing at its corner shows it too) or on keyboard focus. */
+ opacity: 0;
+ transition: opacity 0.15s ease;
+}
+
+.cardWrapper:hover .previewButton,
+.previewButton:focus-visible {
+ opacity: 1;
+}
+
+.previewButton:focus-visible {
+ outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9);
+ outline-offset: 2px;
+}
+
+.previewButton:hover {
+ background: #fff;
}
.cardImage {
@@ -78,6 +216,40 @@
background: #f0f0f0;
}
+.cardImageFallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: var(--wpds-color-bg-surface-neutral, #f0f0f0);
+ color: var(--wpds-color-fg-content-neutral-weak, #666);
+ font-size: 0.8125rem;
+}
+
+/* Thumbnail for the Empty Site card — a fixed dark slate with a faint grid
+ and a document glyph, identical in both color schemes by design. */
+.emptyMedia {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: #1f1f1f;
+ color: #fff;
+ overflow: hidden;
+}
+
+.emptyMediaGrid {
+ position: absolute;
+ inset: 0;
+ background-image:
+ linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
+ background-size: 32px 32px;
+}
+
.cardBody {
display: flex;
flex-direction: column;
@@ -88,6 +260,9 @@
.cardTitle {
font-size: 0.95rem;
font-weight: 600;
+ /* The global h3 rule pins line-height to the fixed 28px lg token, which
+ reads double-spaced at this reduced font size once a title wraps. */
+ line-height: 1.3;
margin: 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/create-site-form/index.tsx b/apps/ui/src/components/create-site-form/index.tsx
index 5335afadd7..be86874257 100644
--- a/apps/ui/src/components/create-site-form/index.tsx
+++ b/apps/ui/src/components/create-site-form/index.tsx
@@ -5,10 +5,12 @@ import { RecommendedPHPVersion } from '@studio/common/types/php-versions';
import { BaseControl, CheckboxControl } from '@wordpress/components';
import { DataForm, useFormValidity } from '@wordpress/dataviews';
import { __, sprintf } from '@wordpress/i18n';
-import { chevronDown, chevronRight } from '@wordpress/icons';
+import { chevronLeft, chevronDown, chevronRight } from '@wordpress/icons';
import { Button, Icon } from '@wordpress/ui';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { BusyOverlay } from '@/components/busy-overlay';
import { LearnHowLink, LearnMoreLink } from '@/components/learn-more';
+import { OnboardingFooter } from '@/components/onboarding-footer';
import {
adminEmailField,
adminPasswordField,
@@ -21,6 +23,7 @@ import {
} from '@/components/site-fields';
import { usePathValidator } from '@/data/queries/use-create-site-helpers';
import { useSites } from '@/data/queries/use-sites';
+import { useWordPressVersions } from '@/data/queries/use-wordpress-versions';
import styles from './style.module.css';
import type { SupportedPHPVersion } from '@studio/common/types/php-versions';
import type {
@@ -302,6 +305,36 @@ export function CreateSiteForm( {
} );
}, [ initialValues ] );
+ const { data: wpVersions } = useWordPressVersions();
+
+ // Land keyboard focus in the Site name field on mount — it's the first
+ // thing every flow asks for. The onboarding layout's heading-focus
+ // fallback yields when a page claims focus itself.
+ const formRef = useRef< HTMLFormElement >( null );
+ useEffect( () => {
+ const input = formRef.current?.querySelector< HTMLInputElement >(
+ 'input[type="text"], input:not([type])'
+ );
+ input?.focus();
+ }, [] );
+
+ // Drop a wpVersion that isn't in the installable-versions list (e.g. a
+ // blueprint preferring a release below the minimum supported version) —
+ // mirrors the desktop renderer, which silently ignores unsupported
+ // preferred versions. Keyed on the current value as well as the list:
+ // initial values seed asynchronously, so with a warm versions cache the
+ // list alone would never change again and a late seed would slip through.
+ useEffect( () => {
+ if ( ! wpVersions?.length ) {
+ return;
+ }
+ setData( ( prev ) =>
+ wpVersions.some( ( version ) => version.value === prev.wpVersion )
+ ? prev
+ : { ...prev, wpVersion: DEFAULT_WORDPRESS_VERSION }
+ );
+ }, [ wpVersions, data.wpVersion ] );
+
const fields = useMemo< Field< FormData >[] >(
() => [
siteNameField< FormData >(),
@@ -321,7 +354,7 @@ export function CreateSiteForm( {
},
},
phpVersionField< FormData >(),
- wpVersionField< FormData >( DEFAULT_WORDPRESS_VERSION ),
+ wpVersionField< FormData >( DEFAULT_WORDPRESS_VERSION, wpVersions ),
adminUsernameField< FormData >(),
adminPasswordField< FormData >(),
adminEmailField< FormData >(),
@@ -335,7 +368,7 @@ export function CreateSiteForm( {
Edit: EnableHttpsControl,
},
],
- [ existingDomainNames ]
+ [ existingDomainNames, wpVersions ]
);
const basicForm = useMemo< Form >(
@@ -425,70 +458,100 @@ export function CreateSiteForm( {
const advancedErrorCount = countAdvancedErrors( validity, advancedForm );
- return (
-