From b09ea9023887ed78bb1b3b5e6c1fd39bde0a0f59 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 12:35:14 -0400 Subject: [PATCH 01/24] apps/ui: handle add-site menu event and blueprint deeplinks The File > Add Site menu item (Cmd+N) and wp-studio://add-site blueprint deeplinks fired IPC events that only the legacy renderer listened for, so both were dead in the new UI. Subscribe to them through the connector and route into the onboarding flow, handing deep-linked blueprints to the configure step via a pending-blueprint slot. Co-Authored-By: Claude Fable 5 --- apps/ui/src/data/core/connectors/ipc/index.ts | 15 +++ apps/ui/src/data/core/types.ts | 13 ++ .../src/hooks/use-add-site-listener.test.tsx | 111 ++++++++++++++++++ apps/ui/src/hooks/use-add-site-listener.ts | 54 +++++++++ apps/ui/src/lib/pending-blueprint.ts | 25 ++++ .../ui-classic/router/layout-root/index.tsx | 8 +- .../route-onboarding-blueprint/index.tsx | 33 ++++-- 7 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 apps/ui/src/hooks/use-add-site-listener.test.tsx create mode 100644 apps/ui/src/hooks/use-add-site-listener.ts create mode 100644 apps/ui/src/lib/pending-blueprint.ts diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index aaee014cad..f3f3ff9f46 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -356,6 +356,21 @@ export function createIpcConnector(): Connector { await ipcApi.cleanupBlueprintTempDir( tempDir ); }, + async readBlueprintFile( filePath ): Promise< Record< string, unknown > > { + return ( await ipcApi.readBlueprintFile( filePath ) ) as Record< string, unknown >; + }, + + onAddSiteRequested( listener ) { + return ipcListener.subscribe( 'add-site', () => listener() ); + }, + + onAddSiteWithBlueprint( listener ) { + return ipcListener.subscribe( + 'add-site-with-blueprint', + ( _event: unknown, payload: { blueprintPath: string } ) => listener( payload ) + ); + }, + async importSiteFromBackup( siteId, backup ): Promise< SiteDetails > { return ( await ipcApi.importSite( { id: siteId, diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 356fbee7f3..034fe82068 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -162,6 +162,19 @@ export interface Connector { extractBlueprintBundle( zipFilePath: string ): Promise< ExtractedBlueprintBundle >; cleanupBlueprintTempDir( tempDir: string ): Promise< void >; + // Reads a Blueprint JSON file from disk. Used by the `wp-studio://add-site` + // deep link, which downloads and validates the blueprint in the main + // process and hands the renderer a temp file path to read it back from. + readBlueprintFile( filePath: string ): Promise< Record< string, unknown > >; + + // Fires when the user asks to add a site from outside the renderer — + // currently the File ▸ Add Site… menu item (⌘N). + onAddSiteRequested( listener: () => void ): () => void; + // Fires when a `wp-studio://add-site?blueprint_url=…` (or `blueprint=…`) + // deep link arrives. The payload points at a blueprint JSON file the main + // process already downloaded and validated; read it via `readBlueprintFile`. + onAddSiteWithBlueprint( listener: ( payload: { blueprintPath: string } ) => void ): () => void; + // Imports a backup archive into an already-created site. Extracts the // archive, installs the SQLite integration if missing, then imports the // archive's database + wp-content on top of the site's folder. diff --git a/apps/ui/src/hooks/use-add-site-listener.test.tsx b/apps/ui/src/hooks/use-add-site-listener.test.tsx new file mode 100644 index 0000000000..918d3c2fd7 --- /dev/null +++ b/apps/ui/src/hooks/use-add-site-listener.test.tsx @@ -0,0 +1,111 @@ +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConnectorProvider } from '@/data/core'; +import { clearPendingBlueprint, peekPendingBlueprint } from '@/lib/pending-blueprint'; +import { useAddSiteListener } from './use-add-site-listener'; +import type { Connector } from '@/data/core'; + +const routerMock = vi.hoisted( () => ( { + navigate: vi.fn(), +} ) ); + +vi.mock( '@tanstack/react-router', () => ( { + useNavigate: () => routerMock.navigate, +} ) ); + +function HookHost() { + useAddSiteListener(); + return null; +} + +function renderListener( connectorOverrides: Partial< Connector > = {} ) { + const addSiteListeners: Array< () => void > = []; + const blueprintListeners: Array< ( payload: { blueprintPath: string } ) => void > = []; + + const connector = { + onAddSiteRequested( listener: () => void ) { + addSiteListeners.push( listener ); + return () => {}; + }, + onAddSiteWithBlueprint( listener: ( payload: { blueprintPath: string } ) => void ) { + blueprintListeners.push( listener ); + return () => {}; + }, + readBlueprintFile: vi.fn().mockResolvedValue( { + meta: { title: 'My Blueprint', description: 'A test blueprint' }, + steps: [], + } ), + ...connectorOverrides, + } as unknown as Connector; + + render( + + + + ); + + return { + connector, + emitAddSite: () => addSiteListeners.forEach( ( listener ) => listener() ), + emitAddSiteWithBlueprint: ( payload: { blueprintPath: string } ) => + blueprintListeners.forEach( ( listener ) => listener( payload ) ), + }; +} + +describe( 'useAddSiteListener', () => { + beforeEach( () => { + routerMock.navigate.mockReset(); + clearPendingBlueprint(); + } ); + + it( 'navigates to onboarding when the add-site menu event fires', () => { + const { emitAddSite } = renderListener(); + + emitAddSite(); + + expect( routerMock.navigate ).toHaveBeenCalledWith( { to: '/onboarding' } ); + } ); + + it( 'stores the deep-linked blueprint and lands on the configure step', async () => { + const { connector, emitAddSiteWithBlueprint } = renderListener(); + + emitAddSiteWithBlueprint( { blueprintPath: '/tmp/blueprint-123.json' } ); + + await waitFor( () => + expect( routerMock.navigate ).toHaveBeenCalledWith( { + to: '/onboarding/blueprint', + search: { step: 'configure' }, + } ) + ); + expect( connector.readBlueprintFile ).toHaveBeenCalledWith( '/tmp/blueprint-123.json' ); + expect( peekPendingBlueprint() ).toMatchObject( { + title: 'My Blueprint', + excerpt: 'A test blueprint', + } ); + } ); + + it( 'falls back to a generic title when the blueprint has no meta', async () => { + const { emitAddSiteWithBlueprint } = renderListener( { + readBlueprintFile: vi.fn().mockResolvedValue( { steps: [] } ), + } ); + + emitAddSiteWithBlueprint( { blueprintPath: '/tmp/blueprint-456.json' } ); + + await waitFor( () => expect( routerMock.navigate ).toHaveBeenCalled() ); + expect( peekPendingBlueprint() ).toMatchObject( { title: 'Blueprint' } ); + } ); + + it( 'does not navigate when the blueprint file cannot be read', async () => { + const consoleError = vi.spyOn( console, 'error' ).mockImplementation( () => {} ); + const { connector, emitAddSiteWithBlueprint } = renderListener( { + readBlueprintFile: vi.fn().mockRejectedValue( new Error( 'missing file' ) ), + } ); + + emitAddSiteWithBlueprint( { blueprintPath: '/tmp/gone.json' } ); + + await waitFor( () => expect( connector.readBlueprintFile ).toHaveBeenCalled() ); + expect( routerMock.navigate ).not.toHaveBeenCalled(); + expect( peekPendingBlueprint() ).toBeNull(); + consoleError.mockRestore(); + } ); +} ); diff --git a/apps/ui/src/hooks/use-add-site-listener.ts b/apps/ui/src/hooks/use-add-site-listener.ts new file mode 100644 index 0000000000..5e55098c85 --- /dev/null +++ b/apps/ui/src/hooks/use-add-site-listener.ts @@ -0,0 +1,54 @@ +import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprint-settings'; +import { useNavigate } from '@tanstack/react-router'; +import { __ } from '@wordpress/i18n'; +import { useEffect } from 'react'; +import { useConnector } from '@/data/core'; +import { setPendingBlueprint } from '@/lib/pending-blueprint'; +import type { BlueprintV1Declaration } from '@wp-playground/blueprints'; + +/** + * Bridges renderer-external "add a site" requests into the router. Two + * sources, both originating in the main process: + * + * - The File ▸ Add Site… menu item (⌘N) fires a plain `add-site` event — + * navigate to the onboarding flow picker. + * - A `wp-studio://add-site?blueprint_url=…` deep link fires + * `add-site-with-blueprint` with the path of a blueprint JSON the main + * process already downloaded and validated. Load it, stash it in the + * pending-blueprint slot, and land directly on the configure step. + * + * Must be mounted inside the RouterProvider (it navigates); the classic + * router's root layout is the canonical spot. + */ +export function useAddSiteListener(): void { + const connector = useConnector(); + const navigate = useNavigate(); + + useEffect( () => { + return connector.onAddSiteRequested( () => { + void navigate( { to: '/onboarding' } ); + } ); + }, [ connector, navigate ] ); + + useEffect( () => { + return connector.onAddSiteWithBlueprint( async ( { blueprintPath } ) => { + try { + const blueprintJson = await connector.readBlueprintFile( blueprintPath ); + const blueprint = blueprintJson as BlueprintV1Declaration; + const meta = ( blueprintJson as { meta?: { title?: string; description?: string } } ).meta; + + setPendingBlueprint( { + title: meta?.title || __( 'Blueprint' ), + excerpt: meta?.description || generateDefaultBlueprintDescription( blueprint ), + blueprint, + } ); + void navigate( { + to: '/onboarding/blueprint', + search: { step: 'configure' }, + } ); + } catch ( error ) { + console.error( 'Failed to load blueprint from deep link:', error ); + } + } ); + }, [ connector, navigate ] ); +} diff --git a/apps/ui/src/lib/pending-blueprint.ts b/apps/ui/src/lib/pending-blueprint.ts new file mode 100644 index 0000000000..7c737f6fe4 --- /dev/null +++ b/apps/ui/src/lib/pending-blueprint.ts @@ -0,0 +1,25 @@ +import type { PickedBlueprint } from '@/components/blueprint-selector'; + +/** + * One-slot handoff for a blueprint that arrives from outside the blueprint + * route's own UI — currently the `wp-studio://add-site` deep link. The + * listener stores the blueprint here and navigates to the configure step; + * the route adopts it into component state on arrival and clears the slot. + * + * Split into peek/clear (rather than an atomic take) so React StrictMode's + * double-invoked effects can't consume the value on the first pass and + * bounce the user back to the select step on the second. + */ +let pendingBlueprint: PickedBlueprint | null = null; + +export function setPendingBlueprint( blueprint: PickedBlueprint ): void { + pendingBlueprint = blueprint; +} + +export function peekPendingBlueprint(): PickedBlueprint | null { + return pendingBlueprint; +} + +export function clearPendingBlueprint(): void { + pendingBlueprint = null; +} diff --git a/apps/ui/src/ui-classic/router/layout-root/index.tsx b/apps/ui/src/ui-classic/router/layout-root/index.tsx index cf750c6646..96b5765e1a 100644 --- a/apps/ui/src/ui-classic/router/layout-root/index.tsx +++ b/apps/ui/src/ui-classic/router/layout-root/index.tsx @@ -1,4 +1,5 @@ import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'; +import { useAddSiteListener } from '@/hooks/use-add-site-listener'; import type { Connector } from '@/data/core'; import type { QueryClient } from '@tanstack/react-query'; @@ -7,6 +8,11 @@ export interface RouterContext { connector: Connector; } +function RootLayout() { + useAddSiteListener(); + return ; +} + export const rootRoute = createRootRouteWithContext< RouterContext >()( { - component: () => , + component: RootLayout, } ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx index 099c5b8c0c..aceee92bb2 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx @@ -13,6 +13,7 @@ import { CreateSiteForm } from '@/components/create-site-form'; import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; import { useFeaturedBlueprints } from '@/data/queries/use-featured-blueprints'; import { useCreateSite } from '@/data/queries/use-sites'; +import { peekPendingBlueprint, clearPendingBlueprint } from '@/lib/pending-blueprint'; import { onboardingLayoutRoute } from '../layout-onboarding'; import styles from '../layout-onboarding/style.module.css'; import localStyles from './style.module.css'; @@ -35,21 +36,39 @@ function OnboardingBlueprintPage() { // Picked blueprint lives in component state — survives navigation between // steps but not a hard refresh. If the user lands on `step=configure` with - // no picked blueprint (refresh, direct URL), the effect below bounces them + // no picked blueprint (refresh, direct URL), the effect below first checks + // the pending-blueprint slot (populated by the `wp-studio://add-site` deep + // link before it navigates here) and adopts it; otherwise it bounces them // back to the selector. const [ picked, setPicked ] = useState< PickedBlueprint | null >( null ); const [ submitError, setSubmitError ] = useState( '' ); useEffect( () => { - if ( activeStep === 'configure' && ! picked ) { - void navigate( { - to: '/onboarding/blueprint', - search: { step: 'select' }, - replace: true, - } ); + if ( activeStep !== 'configure' || picked ) { + return; } + const pending = peekPendingBlueprint(); + if ( pending ) { + setPicked( pending ); + setSubmitError( '' ); + return; + } + void navigate( { + to: '/onboarding/blueprint', + search: { step: 'select' }, + replace: true, + } ); }, [ activeStep, picked, navigate ] ); + // Clear the slot only after the pending blueprint is safely adopted into + // state — a peek-then-clear split keeps StrictMode's double-invoked + // effects from consuming the value before the second pass reads it. + useEffect( () => { + if ( picked ) { + clearPendingBlueprint(); + } + }, [ picked ] ); + const handlePick = useCallback( ( blueprint: PickedBlueprint ) => { // `flushSync` commits the state updates *before* `navigate` fires so From f775e43e472ca7faaa84b82d6bd150b2afad718a Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 12:44:09 -0400 Subject: [PATCH 02/24] apps/ui: port Add Site visual design to classic onboarding Brings the redesigned Add Site look to the ui-classic onboarding flow: animated illustrations on the flow-picker cards, the interactive dot-grid backdrop, frosted-glass card treatment with brand hover states, and centered heading copy matching the desktop renderer. The import card is now a live drop target that jumps straight to the configure step, and the backup filename helpers are shared instead of duplicated per UI. Co-Authored-By: Claude Fable 5 --- apps/ui/src/components/dot-grid/index.tsx | 436 ++++++++++++++++++ .../src/components/dot-grid/style.module.css | 8 + .../onboarding-illustrations/index.tsx | 198 ++++++++ .../onboarding-illustrations/style.module.css | 89 ++++ .../components/onboarding-layout/index.tsx | 8 + apps/ui/src/lib/backup-files.ts | 22 + apps/ui/src/lib/pending-backup.ts | 31 ++ .../router/layout-onboarding/index.tsx | 17 + .../router/layout-onboarding/style.module.css | 57 +-- .../router/route-onboarding-home/index.tsx | 136 +++++- .../route-onboarding-home/style.module.css | 90 ++++ .../router/route-onboarding-import/index.tsx | 55 ++- apps/ui/src/ui-desks/onboarding/index.tsx | 17 +- 13 files changed, 1054 insertions(+), 110 deletions(-) create mode 100644 apps/ui/src/components/dot-grid/index.tsx create mode 100644 apps/ui/src/components/dot-grid/style.module.css create mode 100644 apps/ui/src/components/onboarding-illustrations/index.tsx create mode 100644 apps/ui/src/components/onboarding-illustrations/style.module.css create mode 100644 apps/ui/src/lib/backup-files.ts create mode 100644 apps/ui/src/lib/pending-backup.ts create mode 100644 apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css diff --git a/apps/ui/src/components/dot-grid/index.tsx b/apps/ui/src/components/dot-grid/index.tsx new file mode 100644 index 0000000000..618362913d --- /dev/null +++ b/apps/ui/src/components/dot-grid/index.tsx @@ -0,0 +1,436 @@ +import { useEffect, useRef } from 'react'; +import styles from './style.module.css'; + +/** + * Animated dot-grid backdrop, ported from the desktop renderer's + * `DotGrid`. Renders a grid of crosses joined by dashed lines on a canvas; + * dots spring away from the cursor, mouse-down focuses the repulsion radius, + * and mouse-up emits a ripple. Honors `prefers-reduced-motion` by drawing a + * static grid instead. The draw color comes from the canvas' computed CSS + * `color`, so it tracks light/dark token changes for free. + */ + +interface DotGridProps { + opacity?: number; + repulsion?: number; + rippleStrength?: number; + spacing?: number; + crossSize?: number; + crossThickness?: number; + className?: string; +} + +const SPRING_K = 0.07; +const DAMPING = 0.8; +const SLEEP_EPS = 0.08; + +const RADIUS_BASE = 150; +const RADIUS_EXPANDED = 30; +const RADIUS_EXPAND_SPEED = 0.28; +const RADIUS_CONTRACT_SPEED = 0.1; + +const RIPPLE_SPEED = 9; +const RIPPLE_HALF_WIDTH = 32; + +const INTRO_SPEED = 18; +const INTRO_FADE_WIDTH = 80; + +const TARGET_MS = 1000 / 60; + +interface Ripple { + x: number; + y: number; + radius: number; + maxRadius: number; +} + +export function DotGrid( { + opacity = 0.25, + repulsion = 0.25, + rippleStrength = 1, + spacing = 24, + crossSize = 4, + crossThickness = 0.75, + className, +}: DotGridProps ) { + const canvasRef = useRef< HTMLCanvasElement >( null ); + + useEffect( () => { + const canvas = canvasRef.current; + if ( ! canvas ) return; + + let ctx: CanvasRenderingContext2D | null = null; + let color = ''; + let mouseX = -9999; + let mouseY = -9999; + let rafId: number | null = null; + let lastTimestamp = 0; + + let currentRadius = RADIUS_BASE; + let targetRadius = RADIUS_BASE; + let ripples: Ripple[] = []; + + let introRadius = 0; + let introComplete = false; + + let cols = 0; + let rows = 0; + let ox: Float32Array; + let oy: Float32Array; + let vx: Float32Array; + let vy: Float32Array; + + function readColor() { + if ( ! canvas ) return; + color = getComputedStyle( canvas ).color; + } + + function initDots() { + if ( ! canvas ) return; + cols = Math.ceil( canvas.offsetWidth / spacing ) + 1; + rows = Math.ceil( canvas.offsetHeight / spacing ) + 1; + const n = cols * rows; + ox = new Float32Array( n ); + oy = new Float32Array( n ); + vx = new Float32Array( n ); + vy = new Float32Array( n ); + } + + function tick( timestamp: number ): boolean { + if ( ! ctx || ! canvas ) return false; + const rawDt = lastTimestamp === 0 ? TARGET_MS : timestamp - lastTimestamp; + lastTimestamp = timestamp; + const dt = Math.min( rawDt / TARGET_MS, 3 ); + + const cssW = canvas.offsetWidth; + const cssH = canvas.offsetHeight; + ctx.clearRect( 0, 0, cssW, cssH ); + ctx.fillStyle = color; + + const expandFactor = 1 - Math.pow( 1 - RADIUS_EXPAND_SPEED, dt ); + const contractFactor = 1 - Math.pow( 1 - RADIUS_CONTRACT_SPEED, dt ); + const lerpFactor = currentRadius < targetRadius ? expandFactor : contractFactor; + currentRadius += ( targetRadius - currentRadius ) * lerpFactor; + const radiusAnimating = Math.abs( currentRadius - targetRadius ) > 0.3; + + if ( ! introComplete ) { + introRadius += INTRO_SPEED * dt; + const diag = Math.sqrt( cssW * cssW + cssH * cssH ); + if ( introRadius > diag + INTRO_FADE_WIDTH ) { + introComplete = true; + ctx.globalAlpha = 1; + } + } + + const dampFactor = Math.pow( DAMPING, dt ); + const cursorActive = mouseX > -9998; + let anyActive = false; + + for ( let r = 0; r < rows; r++ ) { + for ( let c = 0; c < cols; c++ ) { + const i = r * cols + c; + const rx = c * spacing; + const ry = r * spacing; + + let dvx = vx[ i ]; + let dvy = vy[ i ]; + let dox = ox[ i ]; + let doy = oy[ i ]; + + // Hover repulsion + if ( cursorActive ) { + const cx2 = rx + dox; + const cy2 = ry + doy; + const ddx = cx2 - mouseX; + const ddy = cy2 - mouseY; + const dist = Math.sqrt( ddx * ddx + ddy * ddy ); + if ( dist < currentRadius && dist > 0.5 ) { + const force = ( repulsion * ( 1 - dist / currentRadius ) ) / dist; + dvx += force * ddx * dt; + dvy += force * ddy * dt; + } + } + + // Ripple wavefronts + for ( const ripple of ripples ) { + const cx2 = rx + dox; + const cy2 = ry + doy; + const ddx = cx2 - ripple.x; + const ddy = cy2 - ripple.y; + const dist = Math.sqrt( ddx * ddx + ddy * ddy ); + if ( dist > 0.5 ) { + const delta = dist - ripple.radius; + const falloff = Math.exp( -0.5 * ( delta / RIPPLE_HALF_WIDTH ) ** 2 ); + const force = ( rippleStrength * falloff ) / dist; + dvx += force * ddx * dt; + dvy += force * ddy * dt; + } + } + + // Spring toward rest + dvx += SPRING_K * -dox * dt; + dvy += SPRING_K * -doy * dt; + + // Damping + dvx *= dampFactor; + dvy *= dampFactor; + + // Integrate + dox += dvx * dt; + doy += dvy * dt; + + ox[ i ] = dox; + oy[ i ] = doy; + vx[ i ] = dvx; + vy[ i ] = dvy; + + if ( + Math.abs( dvx ) > SLEEP_EPS || + Math.abs( dvy ) > SLEEP_EPS || + Math.abs( dox ) > SLEEP_EPS || + Math.abs( doy ) > SLEEP_EPS + ) { + anyActive = true; + } + } + } + + // Draw dotted connecting lines + ctx.strokeStyle = color; + ctx.lineWidth = crossThickness; + ctx.setLineDash( [ 1, 4 ] ); + for ( let r = 0; r < rows; r++ ) { + for ( let c = 0; c < cols; c++ ) { + const i = r * cols + c; + const x = c * spacing + ox[ i ]; + const y = r * spacing + oy[ i ]; + + if ( ! introComplete ) { + const distFromCorner = Math.sqrt( ( c * spacing ) ** 2 + ( r * spacing ) ** 2 ); + ctx.globalAlpha = Math.max( + 0, + Math.min( 1, ( introRadius - distFromCorner ) / INTRO_FADE_WIDTH ) + ); + } + + // Horizontal line to right neighbor + if ( c < cols - 1 ) { + const ni = r * cols + ( c + 1 ); + const nx = ( c + 1 ) * spacing + ox[ ni ]; + const ny = r * spacing + oy[ ni ]; + ctx.beginPath(); + ctx.moveTo( x + crossSize, y ); + ctx.lineTo( nx - crossSize, ny ); + ctx.stroke(); + } + // Vertical line to bottom neighbor + if ( r < rows - 1 ) { + const ni = ( r + 1 ) * cols + c; + const nx = c * spacing + ox[ ni ]; + const ny = ( r + 1 ) * spacing + oy[ ni ]; + ctx.beginPath(); + ctx.moveTo( x, y + crossSize ); + ctx.lineTo( nx, ny - crossSize ); + ctx.stroke(); + } + } + } + ctx.setLineDash( [] ); + + // Draw crosses on top + ctx.fillStyle = color; + for ( let r = 0; r < rows; r++ ) { + for ( let c = 0; c < cols; c++ ) { + const i = r * cols + c; + const x = c * spacing + ox[ i ]; + const y = r * spacing + oy[ i ]; + + if ( ! introComplete ) { + const distFromCorner = Math.sqrt( ( c * spacing ) ** 2 + ( r * spacing ) ** 2 ); + ctx.globalAlpha = Math.max( + 0, + Math.min( 1, ( introRadius - distFromCorner ) / INTRO_FADE_WIDTH ) + ); + } + + ctx.fillRect( x - crossSize, y - crossThickness / 2, crossSize * 2, crossThickness ); + ctx.fillRect( x - crossThickness / 2, y - crossSize, crossThickness, crossSize * 2 ); + } + } + + if ( ! introComplete ) ctx.globalAlpha = 1; + + for ( const ripple of ripples ) ripple.radius += RIPPLE_SPEED * dt; + ripples = ripples.filter( ( rip ) => rip.radius < rip.maxRadius ); + + return anyActive || cursorActive || radiusAnimating || ripples.length > 0 || ! introComplete; + } + + function loop( timestamp: number ) { + if ( tick( timestamp ) ) { + rafId = requestAnimationFrame( loop ); + } else { + rafId = null; + lastTimestamp = 0; + } + } + + function ensureLoop() { + if ( rafId === null ) { + lastTimestamp = 0; + rafId = requestAnimationFrame( loop ); + } + } + + function drawStatic() { + if ( ! ctx || ! canvas ) return; + const cssW = canvas.offsetWidth; + const cssH = canvas.offsetHeight; + ctx.clearRect( 0, 0, cssW, cssH ); + + ctx.strokeStyle = color; + ctx.lineWidth = crossThickness; + ctx.setLineDash( [ 1, 4 ] ); + for ( let r = 0; r < rows; r++ ) { + for ( let c = 0; c < cols; c++ ) { + const x = c * spacing; + const y = r * spacing; + if ( c < cols - 1 ) { + ctx.beginPath(); + ctx.moveTo( x + crossSize, y ); + ctx.lineTo( ( c + 1 ) * spacing - crossSize, y ); + ctx.stroke(); + } + if ( r < rows - 1 ) { + ctx.beginPath(); + ctx.moveTo( x, y + crossSize ); + ctx.lineTo( x, ( r + 1 ) * spacing - crossSize ); + ctx.stroke(); + } + } + } + ctx.setLineDash( [] ); + + ctx.fillStyle = color; + for ( let r = 0; r < rows; r++ ) { + for ( let c = 0; c < cols; c++ ) { + const x = c * spacing; + const y = r * spacing; + ctx.fillRect( x - crossSize, y - crossThickness / 2, crossSize * 2, crossThickness ); + ctx.fillRect( x - crossThickness / 2, y - crossSize, crossThickness, crossSize * 2 ); + } + } + } + + function setupCanvas() { + if ( ! canvas ) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round( canvas.offsetWidth * dpr ); + canvas.height = Math.round( canvas.offsetHeight * dpr ); + ctx = canvas.getContext( '2d' )!; + ctx.scale( dpr, dpr ); + readColor(); + initDots(); + } + + function resize() { + setupCanvas(); + ensureLoop(); + } + + function resizeStatic() { + setupCanvas(); + drawStatic(); + } + + const prefersReducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; + + if ( prefersReducedMotion ) { + resizeStatic(); + + const resizeObserver = new ResizeObserver( resizeStatic ); + resizeObserver.observe( canvas ); + + const mediaQuery = window.matchMedia( '(prefers-color-scheme: dark)' ); + const onColorChange = () => { + readColor(); + drawStatic(); + }; + mediaQuery.addEventListener( 'change', onColorChange ); + + return () => { + resizeObserver.disconnect(); + mediaQuery.removeEventListener( 'change', onColorChange ); + }; + } + + function onMouseMove( e: MouseEvent ) { + if ( ! canvas ) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const inside = x >= 0 && x <= rect.width && y >= 0 && y <= rect.height; + mouseX = inside ? x : -9999; + mouseY = inside ? y : -9999; + ensureLoop(); + } + + function onMouseDown( e: MouseEvent ) { + if ( ! canvas ) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + if ( x >= 0 && x <= rect.width && y >= 0 && y <= rect.height ) { + targetRadius = RADIUS_EXPANDED; + ensureLoop(); + } + } + + function onMouseUp( e: MouseEvent ) { + if ( ! canvas ) return; + targetRadius = RADIUS_BASE; + if ( mouseX > -9998 ) { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const diag = Math.sqrt( rect.width ** 2 + rect.height ** 2 ); + ripples.push( { + x, + y, + radius: currentRadius * 0.85, + maxRadius: diag + RIPPLE_HALF_WIDTH * 4, + } ); + } + ensureLoop(); + } + + resize(); + + document.addEventListener( 'mousemove', onMouseMove ); + document.addEventListener( 'mousedown', onMouseDown ); + document.addEventListener( 'mouseup', onMouseUp ); + + const resizeObserver = new ResizeObserver( resize ); + resizeObserver.observe( canvas ); + + const mediaQuery = window.matchMedia( '(prefers-color-scheme: dark)' ); + mediaQuery.addEventListener( 'change', readColor ); + + return () => { + if ( rafId !== null ) cancelAnimationFrame( rafId ); + document.removeEventListener( 'mousemove', onMouseMove ); + document.removeEventListener( 'mousedown', onMouseDown ); + document.removeEventListener( 'mouseup', onMouseUp ); + resizeObserver.disconnect(); + mediaQuery.removeEventListener( 'change', readColor ); + }; + }, [ spacing, repulsion, rippleStrength, crossSize, crossThickness ] ); + + return ( + + ); +} diff --git a/apps/ui/src/components/dot-grid/style.module.css b/apps/ui/src/components/dot-grid/style.module.css new file mode 100644 index 0000000000..cea5eb57b8 --- /dev/null +++ b/apps/ui/src/components/dot-grid/style.module.css @@ -0,0 +1,8 @@ +.canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} diff --git a/apps/ui/src/components/onboarding-illustrations/index.tsx b/apps/ui/src/components/onboarding-illustrations/index.tsx new file mode 100644 index 0000000000..ffab6bfafd --- /dev/null +++ b/apps/ui/src/components/onboarding-illustrations/index.tsx @@ -0,0 +1,198 @@ +/** + * Illustrations for the onboarding flow-picker cards, ported from the + * desktop renderer's Add Site options screen. Strokes and fills use design + * tokens so they adapt to light/dark mode without overrides. + * + * Rest animations are kept very subtle — a single dashed accent spins slowly + * on each illustration. On hover (triggered by `illustrationHostClass` on the + * parent card), a secondary element picks up the brand color and a second + * motion kicks in. + */ + +import styles from './style.module.css'; + +const STROKE = 'var(--wpds-color-fg-content-neutral, #1e1e1e)'; + +/** Apply to the hoverable card that contains an illustration. */ +export const illustrationHostClass = styles.host; + +export function BuildNewSiteIllustration() { + return ( + + ); +} + +export function StartFromBlueprintIllustration() { + return ( + + ); +} + +export function ConnectSiteIllustration() { + return ( + + ); +} + +export function DropBackupIllustration() { + return ( + + ); +} diff --git a/apps/ui/src/components/onboarding-illustrations/style.module.css b/apps/ui/src/components/onboarding-illustrations/style.module.css new file mode 100644 index 0000000000..dc0bdcb6aa --- /dev/null +++ b/apps/ui/src/components/onboarding-illustrations/style.module.css @@ -0,0 +1,89 @@ +/* Animation and hover treatment for the onboarding illustrations. The + `host` class goes on the hoverable card that contains an illustration — + hover effects on individual SVG parts key off it, mirroring the Tailwind + `group`/`group-hover` pairing the desktop renderer uses. */ + +.host { + /* Hover scope only — no visual styles of its own. */ +} + +/* Rotate SVG children around their own centroid rather than the + document origin. */ +.spin, +.hoverSpinReverse { + transform-box: fill-box; + transform-origin: center; +} + +.spin { + animation: spin 20s linear infinite; +} + +.host:hover .hoverSpinReverse { + animation: spin 24s linear infinite reverse; +} + +.themeStroke { + transition: stroke 0.15s ease; +} + +.host:hover .themeStroke { + stroke: var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +.themeFill { + transition: fill 0.15s ease; +} + +.host:hover .themeFill { + fill: var(--wpds-color-fg-interactive-brand, #3858e9); +} + +.host:hover .arrowNudge { + animation: arrow-nudge 1.2s ease-in-out infinite; +} + +.host:hover .cardShift { + animation: card-shift 3s ease-in-out infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes arrow-nudge { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(2px); + } +} + +@keyframes card-shift { + 0%, + 100% { + transform: translate(0, 0); + } + + 50% { + transform: translate(-3px, -3px); + } +} + +@media (prefers-reduced-motion: reduce) { + .spin, + .host:hover .hoverSpinReverse, + .host:hover .arrowNudge, + .host:hover .cardShift { + animation: none; + } +} diff --git a/apps/ui/src/components/onboarding-layout/index.tsx b/apps/ui/src/components/onboarding-layout/index.tsx index a8928ad396..958c8e93d4 100644 --- a/apps/ui/src/components/onboarding-layout/index.tsx +++ b/apps/ui/src/components/onboarding-layout/index.tsx @@ -18,15 +18,23 @@ interface OnboardingLayoutProps { * content (e.g. the blueprint selector). */ width?: 'default' | 'wide'; + /** + * Decorative layer rendered behind the content (e.g. the dot-grid + * backdrop). The caller positions it; it paints under the content and + * close button by DOM order. + */ + background?: ReactNode; } export function OnboardingLayout( { children, onClose, width = 'default', + background, }: OnboardingLayoutProps ) { return ( + { background } { onClose && ( lower.endsWith( ext ) ); +} + +/** + * Derives a friendly default site name from a backup filename. Strips the + * archive extension and common "site-backup-2024-01-01" date suffixes so the + * form can seed the site name without the user having to retype it. + */ +export function nameFromFilename( filename: string ): string { + const basename = filename.replace( /^.*[\\/]/, '' ); + const lower = basename.toLowerCase(); + const ext = ACCEPTED_IMPORT_FILE_TYPES.find( ( candidate ) => lower.endsWith( candidate ) ); + return ( ext ? basename.slice( 0, -ext.length ) : basename ) + .replace( /[-_](backup|export|wordpress|jetpack)(s)?$/i, '' ) + .replace( /[-_]\d{4}[-_]\d{2}[-_]\d{2}.*$/, '' ) + .replace( /[-_]+/g, ' ' ) + .trim(); +} diff --git a/apps/ui/src/lib/pending-backup.ts b/apps/ui/src/lib/pending-backup.ts new file mode 100644 index 0000000000..5cd739f0f6 --- /dev/null +++ b/apps/ui/src/lib/pending-backup.ts @@ -0,0 +1,31 @@ +/** + * One-slot handoff for a backup archive picked outside the import route's + * own UI — currently the drop-target card on the onboarding home screen. + * The picker stores the file here and navigates to the configure step; the + * route adopts it into component state on arrival and clears the slot. + * + * Same peek/clear split as `pending-blueprint` so React StrictMode's + * double-invoked effects can't consume the value on the first pass and + * bounce the user back to the select step on the second. + */ + +export interface PendingBackup { + file: File; + // Resolved via `connector.getFilePath` at pick-time so the import route + // doesn't have to await the preload bridge again. + path: string; +} + +let pendingBackup: PendingBackup | null = null; + +export function setPendingBackup( backup: PendingBackup ): void { + pendingBackup = backup; +} + +export function peekPendingBackup(): PendingBackup | null { + return pendingBackup; +} + +export function clearPendingBackup(): void { + pendingBackup = null; +} 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 02d2f48b8b..0ab3705e31 100644 --- a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx +++ b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx @@ -1,7 +1,9 @@ import { createRoute, Outlet, useMatches, useNavigate } from '@tanstack/react-router'; +import { DotGrid } from '@/components/dot-grid'; import { OnboardingLayout } from '@/components/onboarding-layout'; import { useSites } from '@/data/queries/use-sites'; import { rootRoute } from '../layout-root'; +import styles from './style.module.css'; function OnboardingShell() { const navigate = useNavigate(); @@ -14,16 +16,31 @@ function OnboardingShell() { // "configure" step reuses the shared site form and should match the // narrow "/onboarding/create" width. const matches = useMatches(); + const isHome = matches.some( ( match ) => match.pathname === '/onboarding' ); const isWide = matches.some( ( match ) => { if ( match.pathname === '/onboarding' ) return true; if ( match.pathname !== '/onboarding/blueprint' ) return false; const step = ( match.search as { step?: string } ).step; return step !== 'configure'; } ); + // The dot grid stays mounted across the whole flow (so its intro sweep + // doesn't replay on every back-navigation) and fades out when the user + // drills into a sub-page, matching the desktop renderer's Add Site modal. + const dotGrid = ( + + ); return ( void navigate( { to: '/dashboard' } ) : undefined } width={ isWide ? 'wide' : 'default' } + background={ dotGrid } > 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..24277e15af 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 @@ -14,50 +14,19 @@ margin: 0 0 32px; } -.cards { - display: flex; - gap: 16px; -} - -.card { - position: relative; - display: block; - text-align: left; - padding: 24px; - width: 240px; - border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); - border-radius: 8px; - cursor: pointer; - color: inherit; - text-decoration: none; - background: var(--wpds-color-bg-surface-neutral-strong, #fff); -} - -.card:hover { - border-color: var(--wpds-color-stroke-interactive-neutral, #bbb); -} - -.cardDisabled { - opacity: 0.55; - cursor: not-allowed; -} - -.cardTitle { - font-weight: 600; - margin-bottom: 8px; -} - -.cardBody { - color: var(--wpds-color-fg-content-neutral-weak, #666); - font-size: 0.875rem; +/* Decorative dot-grid backdrop behind the onboarding home screen. Fades + rather than unmounting so the canvas intro sweep doesn't replay when the + user navigates back from a sub-page. */ +.dotGridLayer { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + opacity: 0; + transition: opacity 0.7s ease-out; } -.cardBadge { - position: absolute; - top: 12px; - right: 12px; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--wpds-color-fg-content-neutral-weak, #555); +.dotGridVisible { + opacity: 1; + transition-duration: 0.5s; } diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx index be5ebbe680..4632d6a7ed 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx @@ -1,36 +1,128 @@ -import { createRoute, Link } from '@tanstack/react-router'; +import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { createRoute, Link, useNavigate } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; +import { useCallback, useRef, useState } from 'react'; +import { + BuildNewSiteIllustration, + DropBackupIllustration, + illustrationHostClass, + StartFromBlueprintIllustration, +} from '@/components/onboarding-illustrations'; +import { useConnector } from '@/data/core'; +import { isValidBackupFile } from '@/lib/backup-files'; +import { setPendingBackup } from '@/lib/pending-backup'; import { onboardingLayoutRoute } from '../layout-onboarding'; -import styles from '../layout-onboarding/style.module.css'; +import styles from './style.module.css'; + +const cardClass = `${ styles.card } ${ illustrationHostClass }`; + +/** + * The import card doubles as a drop target, mirroring the desktop renderer's + * options screen: dropping (or browsing to) a valid backup archive skips the + * import route's select step and lands straight on configure, with the file + * handed over through the pending-backup slot. + */ +function ImportDropCard() { + const navigate = useNavigate(); + const connector = useConnector(); + const fileRef = useRef< HTMLInputElement >( null ); + const [ isDragging, setIsDragging ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + const handleFile = useCallback( + async ( file: File | undefined ) => { + if ( ! file ) { + return; + } + if ( ! isValidBackupFile( file ) ) { + setError( __( 'Unsupported file type.' ) ); + return; + } + const path = await connector.getFilePath( file ); + if ( ! path ) { + setError( __( 'Unable to read the file. Try clicking the card to browse instead.' ) ); + return; + } + setError( null ); + setPendingBackup( { file, path } ); + void navigate( { to: '/onboarding/import', search: { step: 'configure' } } ); + }, + [ connector, navigate ] + ); + + return ( + <> + { + void handleFile( event.target.files?.[ 0 ] ); + event.target.value = ''; + } } + /> + + + ); +} function OnboardingHomePage() { return (
-

{ __( 'Start a new site' ) }

+

{ __( 'Add a site' ) }

- { __( 'WordPress can power anything. What are you building?' ) } + { __( 'Start fresh or bring an existing site into your Studio.' ) }

- -

{ __( 'Create new' ) }

-

- { __( 'Start fresh with a blank site and build it with AI' ) } -

- - -

{ __( 'Start from a blueprint' ) }

-

- { __( - 'Pick a featured blueprint or drop in your own to provision plugins, content, and settings.' - ) } -

+ + +
+

{ __( 'Build a new site' ) }

+

+ { __( 'Start fresh with a blank site and build it with AI' ) } +

+
- -

{ __( 'Bring existing' ) }

-

- { __( 'Import from a Jetpack backup or another full-site export' ) } -

+ + +
+

{ __( 'Start from a blueprint' ) }

+

+ { __( + 'Pick a featured blueprint or drop in your own to provision plugins, content, and settings.' + ) } +

+
+
); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css new file mode 100644 index 0000000000..c0612ee16e --- /dev/null +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css @@ -0,0 +1,90 @@ +.page { + width: 100%; + text-align: center; +} + +.title { + font-size: 32px; + font-weight: 500; + line-height: 1.2; + margin: 0 0 8px; +} + +.subtitle { + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 15px; + font-weight: 300; + margin: 0 0 24px; +} + +.cards { + display: flex; + gap: 20px; + justify-content: center; +} + +.card { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px; + border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); + border-radius: 12px; + /* Semi-transparent surface lets the dot grid glow through; the blur keeps + text readable as dots drift underneath. */ + background: color-mix(in srgb, var(--wpds-color-bg-surface-neutral-strong, #fff) 50%, transparent); + backdrop-filter: blur(12px); + appearance: none; + font: inherit; + text-align: center; + color: inherit; + text-decoration: none; + cursor: pointer; + transition: border-color 0.15s ease; +} + +.card:hover, +.cardDragging { + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +.cardDragging { + background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand, #3858e9) 5%, transparent); +} + +.cardText { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cardTitle { + font-size: 16px; + font-weight: 500; + margin: 0; + transition: color 0.15s ease; +} + +.card:hover .cardTitle { + color: var(--wpds-color-fg-interactive-brand, #3858e9); +} + +.cardBody { + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 13px; + line-height: 1.4; + margin: 0; +} + +.cardError { + margin-top: 4px; + font-size: 12px; + color: var(--wpds-color-fg-content-error, #b32d2e); +} + +.hiddenInput { + display: none; +} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx index d210ce266b..b562549796 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx @@ -11,6 +11,8 @@ import { useConnector } from '@/data/core'; import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; import { useImportSite } from '@/data/queries/use-import-site'; import { useCreateSite } from '@/data/queries/use-sites'; +import { isValidBackupFile, nameFromFilename } from '@/lib/backup-files'; +import { clearPendingBackup, peekPendingBackup } from '@/lib/pending-backup'; import { onboardingLayoutRoute } from '../layout-onboarding'; import sharedStyles from '../layout-onboarding/style.module.css'; import styles from './style.module.css'; @@ -29,27 +31,6 @@ interface PickedBackup { path: string; } -function isValidBackupFile( file: File ): boolean { - const lower = file.name.toLowerCase(); - return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) ); -} - -/** - * Derives a friendly default site name from a backup filename. Strips the - * archive extension and common "site-backup-2024-01-01" date suffixes so the - * form can seed the site name without the user having to retype it. - */ -function nameFromFilename( filename: string ): string { - const basename = filename.replace( /^.*[\\/]/, '' ); - const lower = basename.toLowerCase(); - const ext = ACCEPTED_IMPORT_FILE_TYPES.find( ( candidate ) => lower.endsWith( candidate ) ); - return ( ext ? basename.slice( 0, -ext.length ) : basename ) - .replace( /[-_](backup|export|wordpress|jetpack)(s)?$/i, '' ) - .replace( /[-_]\d{4}[-_]\d{2}[-_]\d{2}.*$/, '' ) - .replace( /[-_]+/g, ' ' ) - .trim(); -} - function OnboardingImportPage() { const { step } = onboardingImportRoute.useSearch(); const navigate = useNavigate(); @@ -62,21 +43,39 @@ function OnboardingImportPage() { // Picked backup lives in component state — survives navigation between // steps but not a hard refresh. If the user lands on `step=configure` - // with no picked backup, the effect below bounces them back to select. + // with no picked backup, the effect below first checks the pending-backup + // slot (populated by the drop-target card on the onboarding home screen) + // and adopts it; otherwise it bounces them back to select. const [ picked, setPicked ] = useState< PickedBackup | null >( null ); const [ pickError, setPickError ] = useState< string | null >( null ); const [ submitError, setSubmitError ] = useState( '' ); useEffect( () => { - if ( activeStep === 'configure' && ! picked ) { - void navigate( { - to: '/onboarding/import', - search: { step: 'select' }, - replace: true, - } ); + if ( activeStep !== 'configure' || picked ) { + return; + } + const pending = peekPendingBackup(); + if ( pending ) { + setPicked( pending ); + setPickError( null ); + return; } + void navigate( { + to: '/onboarding/import', + search: { step: 'select' }, + replace: true, + } ); }, [ activeStep, picked, navigate ] ); + // Clear the slot only after the pending backup is safely adopted into + // state — a peek-then-clear split keeps StrictMode's double-invoked + // effects from consuming the value before the second pass reads it. + useEffect( () => { + if ( picked ) { + clearPendingBackup(); + } + }, [ picked ] ); + const handlePick = useCallback( async ( file: File ) => { if ( ! isValidBackupFile( file ) ) { diff --git a/apps/ui/src/ui-desks/onboarding/index.tsx b/apps/ui/src/ui-desks/onboarding/index.tsx index 21bb55e602..a981ef3b56 100644 --- a/apps/ui/src/ui-desks/onboarding/index.tsx +++ b/apps/ui/src/ui-desks/onboarding/index.tsx @@ -20,6 +20,7 @@ import { import { useFeaturedBlueprints } from '@/data/queries/use-featured-blueprints'; import { useImportSite } from '@/data/queries/use-import-site'; import { useCreateSite, useSites } from '@/data/queries/use-sites'; +import { isValidBackupFile, nameFromFilename } from '@/lib/backup-files'; import { Button } from '@/ui-desks/components'; import { desksRootRoute } from '../router/root'; import styles from './style.module.css'; @@ -440,22 +441,6 @@ function mapBlueprintSettingsToFormValues( }; } -function isValidBackupFile( file: File ): boolean { - const lower = file.name.toLowerCase(); - return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) ); -} - -function nameFromFilename( filename: string ): string { - const basename = filename.replace( /^.*[\\/]/, '' ); - const lower = basename.toLowerCase(); - const ext = ACCEPTED_IMPORT_FILE_TYPES.find( ( candidate ) => lower.endsWith( candidate ) ); - return ( ext ? basename.slice( 0, -ext.length ) : basename ) - .replace( /[-_](backup|export|wordpress|jetpack)(s)?$/i, '' ) - .replace( /[-_]\d{4}[-_]\d{2}[-_]\d{2}.*$/, '' ) - .replace( /[-_]+/g, ' ' ) - .trim(); -} - function validateStepSearch( search: Record< string, unknown > ): StepSearch { const value = search.step; if ( value === 'configure' || value === 'select' ) { From 86e09b3bfc1cc2beed4889d0dce73cb522142b2c Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 12:47:57 -0400 Subject: [PATCH 03/24] apps/ui: replace free-text WordPress version field with a version selector The create-site form accepted any string as a WordPress version. Fetch installable versions from the wordpress.org version-check API (same shared helper the desktop renderer uses) and render a select ordered latest > nightly/beta > stable. Falls back to the free-text input when the fetch fails so site creation never blocks offline, and drops blueprint-seeded versions that aren't installable, matching the desktop renderer. Co-Authored-By: Claude Fable 5 --- .../src/components/create-site-form/index.tsx | 22 +++++++++++++-- apps/ui/src/components/site-fields/index.ts | 27 +++++++++++++++++-- apps/ui/src/data/core/connectors/ipc/index.ts | 8 ++++++ apps/ui/src/data/core/types.ts | 7 +++++ .../data/queries/use-wordpress-versions.ts | 20 ++++++++++++++ 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 apps/ui/src/data/queries/use-wordpress-versions.ts diff --git a/apps/ui/src/components/create-site-form/index.tsx b/apps/ui/src/components/create-site-form/index.tsx index 5335afadd7..d390931e46 100644 --- a/apps/ui/src/components/create-site-form/index.tsx +++ b/apps/ui/src/components/create-site-form/index.tsx @@ -21,6 +21,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 +303,23 @@ export function CreateSiteForm( { } ); }, [ initialValues ] ); + const { data: wpVersions } = useWordPressVersions(); + + // When the installable-versions list arrives, drop a seeded wpVersion + // that isn't in it (e.g. a blueprint preferring a release below the + // minimum supported version) — mirrors the desktop renderer, which + // silently ignores unsupported preferred versions. + useEffect( () => { + if ( ! wpVersions?.length ) { + return; + } + setData( ( prev ) => + wpVersions.some( ( version ) => version.value === prev.wpVersion ) + ? prev + : { ...prev, wpVersion: DEFAULT_WORDPRESS_VERSION } + ); + }, [ wpVersions ] ); + const fields = useMemo< Field< FormData >[] >( () => [ siteNameField< FormData >(), @@ -321,7 +339,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 +353,7 @@ export function CreateSiteForm( { Edit: EnableHttpsControl, }, ], - [ existingDomainNames ] + [ existingDomainNames, wpVersions ] ); const basicForm = useMemo< Form >( diff --git a/apps/ui/src/components/site-fields/index.ts b/apps/ui/src/components/site-fields/index.ts index 8d0495ec95..9666a5959d 100644 --- a/apps/ui/src/components/site-fields/index.ts +++ b/apps/ui/src/components/site-fields/index.ts @@ -13,6 +13,7 @@ import { import { validateAdminEmail, validateAdminUsername } from '@studio/common/lib/passwords'; import { SupportedPHPVersions } from '@studio/common/types/php-versions'; import { __ } from '@wordpress/i18n'; +import type { WordPressVersion } from '@studio/common/lib/wordpress-versions'; import type { SupportedPHPVersion } from '@studio/common/types/php-versions'; import type { Field } from '@wordpress/dataviews'; @@ -39,15 +40,37 @@ export function phpVersionField< T extends { phpVersion: SupportedPHPVersion } > }; } +/** + * Builder for the WordPress version field. With a fetched `versions` list it + * renders a select ordered like the desktop renderer's version picker — + * auto-updating "latest" first, then nightly/beta, then stable releases. + * Without one (offline, fetch failed, still loading) it stays a free-text + * input so site creation is never blocked on the wordpress.org API. + */ export function wpVersionField< T extends { wpVersion: string } >( - placeholder: string + placeholder: string, + versions?: WordPressVersion[] ): Field< T > { - return { + const field: Field< T > = { id: 'wpVersion', type: 'text', label: __( 'WordPress version' ), placeholder, }; + if ( versions?.length ) { + const beta = versions.filter( ( version ) => version.isBeta || version.isDevelopment ); + const stable = versions.filter( + ( version ) => version.value !== 'latest' && ! version.isBeta && ! version.isDevelopment + ); + field.elements = [ + ...versions + .filter( ( version ) => version.value === 'latest' ) + .map( ( version ) => ( { value: version.value, label: __( 'latest' ) } ) ), + ...beta.map( ( version ) => ( { value: version.value, label: version.label } ) ), + ...stable.map( ( version ) => ( { value: version.value, label: version.label } ) ), + ]; + } + return field; } export function adminUsernameField< T extends { adminUsername: string } >(): Field< T > { diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index f3f3ff9f46..bda688aa66 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -1,4 +1,5 @@ import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; +import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions'; import { __ } from '@wordpress/i18n'; import type { ActiveAgentRun, @@ -336,6 +337,13 @@ export function createIpcConnector(): Connector { return list; }, + async getWordPressVersions() { + // Fetches straight from the wordpress.org version-check API (the + // renderer CSP allows api.wordpress.org) using the same shared + // helper the desktop renderer's version selector relies on. + return fetchWordPressVersions(); + }, + async getFilePath( file ) { // `webUtils.getPathForFile` is a synchronous preload-only API; the // connector wraps it in a Promise to keep the surface uniform and diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 034fe82068..29d77c976a 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -4,6 +4,7 @@ import type { AiSessionSummary, LoadedAiSession } from '@studio/common/ai/sessio import type { SupportedLocale } from '@studio/common/lib/locale'; import type { SupportedEditor } from '@studio/common/lib/user-settings/editor'; import type { SupportedTerminal } from '@studio/common/lib/user-settings/terminal'; +import type { WordPressVersion } from '@studio/common/lib/wordpress-versions'; import type { DeskConfig, DeskSettings } from '@studio/common/types/desk'; import type { SupportedPHPVersion } from '@studio/common/types/php-versions'; import type { Snapshot } from '@studio/common/types/snapshot'; @@ -28,6 +29,7 @@ export type { export type { AiModelId } from '@studio/common/ai/models'; export type { Snapshot } from '@studio/common/types/snapshot'; export type { SyncSite } from '@studio/common/types/sync'; +export type { WordPressVersion } from '@studio/common/lib/wordpress-versions'; export type { DeskConfig, DeskSettings, DeskWidgetBase } from '@studio/common/types/desk'; export type { SupportedEditor } from '@studio/common/lib/user-settings/editor'; export type { SupportedTerminal } from '@studio/common/lib/user-settings/terminal'; @@ -148,6 +150,11 @@ export interface Connector { // no auth required, localized by the user's current UI locale. getFeaturedBlueprints( locale?: string ): Promise< FeaturedBlueprint[] >; + // Installable WordPress versions from the wordpress.org version-check + // API: a "latest" auto-updating option first, then nightly/beta and + // stable releases down to Playground's minimum supported version. + getWordPressVersions(): Promise< WordPressVersion[] >; + // Resolves the absolute filesystem path of a File handle picked or dropped // in the renderer. Returns an empty string when the underlying file lacks // a real path (synthetic blobs, non-Electron environments). diff --git a/apps/ui/src/data/queries/use-wordpress-versions.ts b/apps/ui/src/data/queries/use-wordpress-versions.ts new file mode 100644 index 0000000000..c42690e4ee --- /dev/null +++ b/apps/ui/src/data/queries/use-wordpress-versions.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { useConnector } from '@/data/core'; + +const WORDPRESS_VERSIONS_QUERY_KEY = [ 'wordpress-versions' ] as const; + +/** + * Installable WordPress versions for the create-site and site-settings + * forms. The list changes only when WordPress ships a release, so keep it + * fresh for an hour; failures fall back to the form's static default + * rather than blocking site creation. + */ +export function useWordPressVersions() { + const connector = useConnector(); + return useQuery( { + queryKey: WORDPRESS_VERSIONS_QUERY_KEY, + queryFn: () => connector.getWordPressVersions(), + staleTime: 60 * 60 * 1000, + retry: 1, + } ); +} From bbf8298bedd4804ad8f5a4aec13c46a751071b02 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 12:54:15 -0400 Subject: [PATCH 04/24] apps/ui: bring blueprint gallery to parity with the desktop Add Site flow The gallery now matches the redesigned desktop experience: an Empty Site card with a Playground live preview, the curated featured trio with display names and pinned ordering, an Explore section with search across titles, excerpts, and categories, and Live Preview pills overlaid on card images. The curation rules (featured slugs, renames, excerpt overrides, order) move into @studio/common so the two UIs share one source of truth. Co-Authored-By: Claude Fable 5 --- apps/studio/src/constants.ts | 2 +- .../add-site/components/new-site-options.tsx | 46 +-- .../components/blueprint-selector/index.tsx | 267 +++++++++++++----- .../blueprint-selector/style.module.css | 87 +++++- .../route-onboarding-blueprint/index.tsx | 5 +- apps/ui/src/ui-desks/onboarding/index.tsx | 5 +- tools/common/constants.ts | 1 + tools/common/lib/blueprint-curation.ts | 56 ++++ 8 files changed, 351 insertions(+), 118 deletions(-) create mode 100644 tools/common/lib/blueprint-curation.ts diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts index 93ca9dd4ba..2e2612d4ad 100644 --- a/apps/studio/src/constants.ts +++ b/apps/studio/src/constants.ts @@ -17,7 +17,7 @@ export const AUTO_UPDATE_INTERVAL_MS = 60 * 60 * 1000; export const NIGHTLY_UPDATE_TTL_MS = 24 * 60 * 60 * 1000; export const MACOS_TRAFFIC_LIGHT_POSITION = { x: 20, y: 20 }; export const WINDOWS_TITLEBAR_HEIGHT = 44; -export const EMPTY_SITE_PLAYGROUND_URL = 'https://playground.wordpress.net/'; +export { EMPTY_SITE_PLAYGROUND_URL } from '@studio/common/constants'; export const ABOUT_WINDOW_WIDTH = 300; export const ABOUT_WINDOW_HEIGHT = 350; export const BUG_REPORT_URL = diff --git a/apps/studio/src/modules/add-site/components/new-site-options.tsx b/apps/studio/src/modules/add-site/components/new-site-options.tsx index 1c6b2599d9..cb6e1eb932 100644 --- a/apps/studio/src/modules/add-site/components/new-site-options.tsx +++ b/apps/studio/src/modules/add-site/components/new-site-options.tsx @@ -1,3 +1,7 @@ +import { + curateBlueprintsForDisplay, + FEATURED_BLUEPRINT_SLUGS, +} from '@studio/common/lib/blueprint-curation'; import { __experimentalVStack as VStack, __experimentalHeading as Heading, @@ -172,46 +176,6 @@ function BlueprintCard( { ); } -const BLUEPRINT_DISPLAY_NAMES: Record< string, string > = { - 'Quick Start': 'WordPress.com', - Development: 'WordPress Dev', - Commerce: 'WooCommerce', -}; - -function getBlueprintExcerptOverrides( __: ( text: string ) => string ): Record< string, string > { - return { - 'Quick Start': __( - 'A WordPress.com-like environment with Business plan plugins and themes pre-installed.' - ), - Commerce: __( - 'Create your next online store with WooCommerce and its companion plugins pre-installed.' - ), - Development: __( 'A streamlined environment for building and testing themes or plugins.' ), - }; -} - -const BLUEPRINT_ORDER: Record< string, number > = { - 'Quick Start': 1, - Commerce: 2, - Development: 3, -}; - -const FEATURED_BLUEPRINT_SLUGS = new Set( [ 'woo-shop', 'development', 'quick-start' ] ); - -function renameBlueprintsForDisplay( - blueprints: Blueprint[], - __: ( text: string ) => string -): Blueprint[] { - const excerptOverrides = getBlueprintExcerptOverrides( __ ); - return [ ...blueprints ] - .sort( ( a, b ) => ( BLUEPRINT_ORDER[ a.title ] ?? 99 ) - ( BLUEPRINT_ORDER[ b.title ] ?? 99 ) ) - .map( ( item ) => ( { - ...item, - excerpt: excerptOverrides[ item.title ] || item.excerpt, - title: BLUEPRINT_DISPLAY_NAMES[ item.title ] || item.title, - } ) ); -} - export function NewSiteOptions( { enableBlueprints, blueprints, @@ -289,7 +253,7 @@ export function NewSiteOptions( { ) : ( enableBlueprints && - renameBlueprintsForDisplay( featuredBlueprints, __ ).map( ( item ) => ( + curateBlueprintsForDisplay( featuredBlueprints, __ ).map( ( item ) => ( 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'; +function getBlueprintCategories( blueprint: FeaturedBlueprint ): string[] { + const meta = ( blueprint.blueprint as { meta?: { categories?: string[] } } ).meta; + return Array.isArray( meta?.categories ) ? meta.categories : []; +} + +/** + * "Live Preview" pill 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(); + return ( +
+ +
+ ); +} + +function EmptySiteCard( { onPick }: { onPick: () => void } ) { + return ( +
  • + + +
  • + ); +} + +function BlueprintCard( { + blueprint, + onPick, +}: { + blueprint: FeaturedBlueprint; + onPick: ( blueprint: FeaturedBlueprint ) => void; +} ) { + return ( +
  • + + { blueprint.playgroundUrl && ( + + ) } +
  • + ); +} + export function BlueprintSelector( { - featured, - isFeaturedLoading, + blueprints, + isLoading, onPick, + onPickEmpty, }: BlueprintSelectorProps ) { const connector = useConnector(); const [ uploadError, setUploadError ] = useState< string | null >( null ); + const [ searchQuery, setSearchQuery ] = useState( '' ); + + // The endpoint returns blueprints oldest-first; newest-first reads better + // in the Explore grid (matches the desktop renderer). + const allBlueprints = useMemo( () => ( blueprints ?? [] ).slice().reverse(), [ blueprints ] ); + + const featuredBlueprints = useMemo( + () => + curateBlueprintsForDisplay( + allBlueprints.filter( ( blueprint ) => FEATURED_BLUEPRINT_SLUGS.has( blueprint.slug ) ), + __ + ), + [ allBlueprints ] + ); + const exploreBlueprints = useMemo( + () => allBlueprints.filter( ( blueprint ) => ! FEATURED_BLUEPRINT_SLUGS.has( blueprint.slug ) ), + [ allBlueprints ] + ); + + const filteredExploreBlueprints = useMemo( () => { + const query = searchQuery.toLowerCase().trim(); + if ( ! query ) { + return exploreBlueprints; + } + return exploreBlueprints.filter( ( blueprint ) => { + const titleMatch = blueprint.title.toLowerCase().includes( query ); + const excerptMatch = blueprint.excerpt.toLowerCase().includes( query ); + const categoryMatch = getBlueprintCategories( blueprint ).some( ( category ) => + category.toLowerCase().includes( query ) + ); + return titleMatch || excerptMatch || categoryMatch; + } ); + }, [ exploreBlueprints, searchQuery ] ); const handleFeaturedClick = useCallback( ( item: FeaturedBlueprint ) => { @@ -51,18 +197,6 @@ export function BlueprintSelector( { [ onPick ] ); - const handlePreviewClick = useCallback( - ( event: React.MouseEvent< HTMLButtonElement >, item: FeaturedBlueprint ) => { - // Stop the click from bubbling to the card's pick handler — the - // user explicitly asked to preview, not to select. - event.stopPropagation(); - if ( item.playgroundUrl ) { - void connector.openExternalUrl( item.playgroundUrl ); - } - }, - [ connector ] - ); - /** * Validates parsed blueprint JSON and hands a `PickedBlueprint` to the * parent. Returns `true` on success so callers can tell whether to clean @@ -172,6 +306,52 @@ export function BlueprintSelector( { return (
    +
    +
      + + { isLoading && ( +
    • + +
    • + ) } + { ! isLoading && allBlueprints.length === 0 && ( +
    • { __( 'Could not load templates.' ) }
    • + ) } + { featuredBlueprints.map( ( item ) => ( + + ) ) } +
    +
    + + { exploreBlueprints.length > 0 && ( +
    +
    +

    { __( 'Explore more blueprints' ) }

    + +
    + { filteredExploreBlueprints.length === 0 ? ( +

    { __( 'No blueprints found.' ) }

    + ) : ( +
      + { filteredExploreBlueprints.map( ( item ) => ( + + ) ) } +
    + ) } +
    + ) } +

    { __( 'Upload your own' ) }

    - -
    -

    { __( 'Featured blueprints' ) }

    - { isFeaturedLoading && ( -

    { __( 'Loading featured blueprints…' ) }

    - ) } - { ! isFeaturedLoading && ( ! featured || featured.length === 0 ) && ( -

    - { __( 'No featured blueprints available right now.' ) } -

    - ) } - { featured && featured.length > 0 && ( -
      - { featured.map( ( item ) => ( -
    • - - { item.playgroundUrl && ( - - ) } -
    • - ) ) } -
    - ) } -
    ); } diff --git a/apps/ui/src/components/blueprint-selector/style.module.css b/apps/ui/src/components/blueprint-selector/style.module.css index 6ee1545b9d..25e3dbbd1a 100644 --- a/apps/ui/src/components/blueprint-selector/style.module.css +++ b/apps/ui/src/components/blueprint-selector/style.module.css @@ -10,12 +10,23 @@ gap: 12px; } +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + .sectionTitle { font-size: 1rem; font-weight: 600; margin: 0; } +.search { + width: 200px; +} + .featuredHint { margin: 0; color: var(--wpds-color-fg-content-neutral-weak, #666); @@ -39,6 +50,14 @@ } } +.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 +66,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 +75,47 @@ 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); +} + +/* 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(0, 0, 0, 0.08); + 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; +} + +.previewButton:hover { + background: #fff; } .cardImage { @@ -78,6 +125,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; diff --git a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx index aceee92bb2..feaaa89c34 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx @@ -148,9 +148,10 @@ function OnboardingBlueprintPage() { ) }

    void navigate( { to: '/onboarding/create' } ) } /> ); diff --git a/apps/ui/src/ui-desks/onboarding/index.tsx b/apps/ui/src/ui-desks/onboarding/index.tsx index a981ef3b56..f3550a8b70 100644 --- a/apps/ui/src/ui-desks/onboarding/index.tsx +++ b/apps/ui/src/ui-desks/onboarding/index.tsx @@ -224,9 +224,10 @@ export function DeskOnboardingBlueprint() { ) }

    void navigate( { to: '/onboarding/create' } ) } /> diff --git a/tools/common/constants.ts b/tools/common/constants.ts index be6310ef86..883c3e6cf5 100644 --- a/tools/common/constants.ts +++ b/tools/common/constants.ts @@ -36,6 +36,7 @@ export const CERT_UNTRUSTED_ROOT = 'CERT_TRUST_IS_UNTRUSTED_ROOT'; // Windows AP export const DEFAULT_CUSTOM_DOMAIN_SUFFIX = '.wp.local'; // WordPress constants +export const EMPTY_SITE_PLAYGROUND_URL = 'https://playground.wordpress.net/'; export const MINIMUM_WORDPRESS_VERSION = '6.2.1' as const; // https://wordpress.github.io/wordpress-playground/blueprints/examples/#load-an-older-wordpress-version export const DEFAULT_WORDPRESS_VERSION = 'latest' as const; export const DEFAULT_PHP_VERSION: typeof RecommendedPHPVersion = RecommendedPHPVersion; diff --git a/tools/common/lib/blueprint-curation.ts b/tools/common/lib/blueprint-curation.ts new file mode 100644 index 0000000000..a1fe39f1d0 --- /dev/null +++ b/tools/common/lib/blueprint-curation.ts @@ -0,0 +1,56 @@ +/** + * Display curation for the public blueprints gallery, shared by the desktop + * renderer's Add Site flow and the apps/ui onboarding flow so the two can't + * drift. The wpcom blueprints endpoint returns raw titles/excerpts; these + * helpers rename the featured trio for display, override their excerpts, + * and pin their order. + */ + +type TranslateFn = ( text: string ) => string; + +export const FEATURED_BLUEPRINT_SLUGS: ReadonlySet< string > = new Set( [ + 'woo-shop', + 'development', + 'quick-start', +] ); + +const BLUEPRINT_DISPLAY_NAMES: Record< string, string > = { + 'Quick Start': 'WordPress.com', + Development: 'WordPress Dev', + Commerce: 'WooCommerce', +}; + +const BLUEPRINT_ORDER: Record< string, number > = { + 'Quick Start': 1, + Commerce: 2, + Development: 3, +}; + +// Takes the translate function as a parameter (rather than importing the +// global `__`) so callers using a scoped i18n instance — like the desktop +// renderer's I18nProvider — still get translated strings. +function getBlueprintExcerptOverrides( __: TranslateFn ): Record< string, string > { + return { + 'Quick Start': __( + 'A WordPress.com-like environment with Business plan plugins and themes pre-installed.' + ), + Commerce: __( + 'Create your next online store with WooCommerce and its companion plugins pre-installed.' + ), + Development: __( 'A streamlined environment for building and testing themes or plugins.' ), + }; +} + +export function curateBlueprintsForDisplay< T extends { title: string; excerpt: string } >( + blueprints: T[], + __: TranslateFn +): T[] { + const excerptOverrides = getBlueprintExcerptOverrides( __ ); + return [ ...blueprints ] + .sort( ( a, b ) => ( BLUEPRINT_ORDER[ a.title ] ?? 99 ) - ( BLUEPRINT_ORDER[ b.title ] ?? 99 ) ) + .map( ( item ) => ( { + ...item, + excerpt: excerptOverrides[ item.title ] || item.excerpt, + title: BLUEPRINT_DISPLAY_NAMES[ item.title ] || item.title, + } ) ); +} From e2b5a84bf03bc93f0894e7b8e50e60958f2d4796 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 12:59:32 -0400 Subject: [PATCH 05/24] apps/ui: add Connect a site onboarding flow Ports the desktop renderer's pull-remote creation path: a fourth flow- picker card (disabled offline) leads to a new /onboarding/connect route with a WordPress.com sign-in view and a picker over syncable sites. On connect, Studio creates the local site without starting its server, persists the connection, kicks off a pull, and lands on the site view where the existing sync-activity indicator shows progress. The connector gains a skipStart create option (mapping to the main process' noStart) and a signup variant of authenticate. Co-Authored-By: Claude Fable 5 --- .../components/onboarding-layout/index.tsx | 15 +- .../onboarding-layout/style.module.css | 6 + apps/ui/src/data/core/connectors/ipc/index.ts | 6 +- apps/ui/src/data/core/types.ts | 8 +- .../data/queries/use-create-site-helpers.ts | 24 ++ apps/ui/src/hooks/use-offline.ts | 23 ++ .../router/layout-onboarding/index.tsx | 2 +- .../router/route-onboarding-connect/index.tsx | 226 ++++++++++++++++++ .../route-onboarding-connect/style.module.css | 138 +++++++++++ .../router/route-onboarding-home/index.tsx | 29 +++ .../route-onboarding-home/style.module.css | 20 ++ apps/ui/src/ui-classic/router/router.tsx | 2 + 12 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 apps/ui/src/hooks/use-offline.ts create mode 100644 apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx create mode 100644 apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css diff --git a/apps/ui/src/components/onboarding-layout/index.tsx b/apps/ui/src/components/onboarding-layout/index.tsx index 958c8e93d4..8357c16e86 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); `'extra-wide'` fits the + * onboarding home's four-card flow picker. */ - width?: 'default' | 'wide'; + width?: 'default' | 'wide' | 'extra-wide'; /** * Decorative layer rendered behind the content (e.g. the dot-grid * backdrop). The caller positions it; it paints under the content and @@ -26,6 +27,12 @@ interface OnboardingLayoutProps { background?: ReactNode; } +const WIDTH_CLASSES = { + default: '', + wide: styles.contentWide, + 'extra-wide': styles.contentExtraWide, +} as const; + export function OnboardingLayout( { children, onClose, @@ -46,9 +53,7 @@ export function OnboardingLayout( { onClick={ onClose } /> ) } -
    - { children } -
    +
    { 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..b583c1e167 100644 --- a/apps/ui/src/components/onboarding-layout/style.module.css +++ b/apps/ui/src/components/onboarding-layout/style.module.css @@ -15,6 +15,12 @@ max-width: 820px; } +/* `extra-wide` variant — fits the onboarding home's four flow-picker cards + side by side. */ +.contentExtraWide { + max-width: 1040px; +} + .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 bda688aa66..b812d77fb1 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -193,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 > { @@ -222,6 +222,7 @@ export function createIpcConnector(): Connector { adminPassword, adminEmail, blueprint, + skipStart, } = params; return ( await ipcApi.createSite( path, { siteName: name, @@ -233,6 +234,7 @@ export function createIpcConnector(): Connector { adminPassword, adminEmail, blueprint, + noStart: skipStart, } ) ) as SiteDetails; }, diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 29d77c976a..4f63edcafd 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -100,7 +100,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; @@ -360,6 +362,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 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..1a7f9461e6 100644 --- a/apps/ui/src/data/queries/use-create-site-helpers.ts +++ b/apps/ui/src/data/queries/use-create-site-helpers.ts @@ -34,6 +34,30 @@ export function useProposedSiteName( sites: SiteDetails[] | undefined ) { } ); } +/** + * Finds a site name that doesn't collide with an existing site folder by + * appending an incrementing suffix ("My Site", "My Site 2", ...). Used when + * a flow seeds the name from an external source — a connected WordPress.com + * site, a blueprint, a backup filename — rather than user input. + */ +export function useFindAvailableSiteName() { + const connector = useConnector(); + return useCallback( + async ( baseName: string ): Promise< string > => { + const MAX_NAME_ITERATIONS = 500; + for ( let suffix = 1; suffix < MAX_NAME_ITERATIONS; suffix++ ) { + const candidateName = suffix === 1 ? baseName : `${ baseName } ${ suffix }`; + const pathInfo = await connector.generateProposedSitePath( candidateName ); + if ( pathInfo.isEmpty ) { + return candidateName; + } + } + return `${ baseName } ${ MAX_NAME_ITERATIONS }`; + }, + [ connector ] + ); +} + /** * Returns two imperative helpers the create form uses to validate paths: * 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/ui-classic/router/layout-onboarding/index.tsx b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx index 0ab3705e31..5371984037 100644 --- a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx +++ b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx @@ -39,7 +39,7 @@ function OnboardingShell() { return ( void navigate( { to: '/dashboard' } ) : undefined } - width={ isWide ? 'wide' : 'default' } + width={ isHome ? 'extra-wide' : isWide ? 'wide' : 'default' } background={ dotGrid } > 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..fafb5d7aac --- /dev/null +++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx @@ -0,0 +1,226 @@ +import { createRoute, useNavigate } from '@tanstack/react-router'; +import { __ } from '@wordpress/i18n'; +import { check, wordpress } from '@wordpress/icons'; +import { Button, Icon } from '@wordpress/ui'; +import { useCallback, useState } from 'react'; +import { useConnector } from '@/data/core'; +import { useAuthUser } from '@/data/queries/use-auth-user'; +import { useFindAvailableSiteName } from '@/data/queries/use-create-site-helpers'; +import { useCreateSite } from '@/data/queries/use-sites'; +import { usePullSiteFromLive } from '@/data/queries/use-sync-site'; +import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites'; +import { useOffline } from '@/hooks/use-offline'; +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'; + +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." ) }

    + ) } +
    +
    + ); +} + +function RemoteSiteList( { + sites, + selectedId, + onSelect, +}: { + sites: SyncSite[]; + selectedId: number | null; + onSelect: ( id: number ) => void; +} ) { + return ( +
      + { sites.map( ( site ) => ( +
    • + +
    • + ) ) } +
    + ); +} + +function OnboardingConnectPage() { + const navigate = useNavigate(); + const connector = useConnector(); + const { data: user, isLoading: isAuthLoading } = useAuthUser(); + const pickable = usePickableWpcomSites( { enabled: !! user } ); + const createSite = useCreateSite(); + const pullSiteFromLive = usePullSiteFromLive(); + const findAvailableSiteName = useFindAvailableSiteName(); + + const [ selectedId, setSelectedId ] = useState< number | null >( null ); + const [ isConnecting, setIsConnecting ] = useState( false ); + const [ submitError, setSubmitError ] = useState( '' ); + + const selectedSite = pickable.data?.find( ( site ) => site.id === selectedId ); + + const handleConnect = useCallback( async () => { + if ( ! selectedSite || isConnecting ) { + return; + } + setSubmitError( '' ); + setIsConnecting( true ); + try { + // Create the local shell first (skipping server start — the pull + // restarts it once the remote content lands), then persist the + // connection and kick off the pull. Mirrors the desktop renderer's + // pull-remote flow. + const availableName = await findAvailableSiteName( selectedSite.name || selectedSite.url ); + const { path } = await connector.generateProposedSitePath( availableName ); + const site = await createSite.mutateAsync( { + name: availableName, + path, + skipStart: true, + } ); + await connector.connectWpcomSite( site.id, { + ...selectedSite, + localSiteId: site.id, + syncSupport: 'already-connected', + } ); + // Fire-and-forget: the pull reports progress through the shared + // sync-activity channel, which the site view surfaces. + pullSiteFromLive.mutate( { siteId: site.id, remoteSiteId: selectedSite.id } ); + await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); + } catch ( error ) { + setIsConnecting( false ); + setSubmitError( + error instanceof Error ? error.message : __( 'Failed to connect site. Please try again.' ) + ); + } + }, [ + selectedSite, + isConnecting, + findAvailableSiteName, + connector, + createSite, + pullSiteFromLive, + navigate, + ] ); + + const isSignedIn = !! user; + const sites = pickable.data ?? []; + + return ( +
    +

    { __( 'Connect a site' ) }

    +

    + { isSignedIn + ? __( 'Select a WordPress.com or Pressable site to bring into your Studio.' ) + : __( 'Connect your WordPress.com account to access your sites.' ) } +

    + + { ! isSignedIn && ! isAuthLoading && } + + { isSignedIn && ( + <> + { pickable.isLoading && ( +

    { __( 'Loading your sites…' ) }

    + ) } + { ! pickable.isLoading && sites.length === 0 && ( +

    + { __( 'No syncable sites found on your WordPress.com account.' ) } +

    + ) } + { sites.length > 0 && ( + + ) } + + { submitError &&
    { submitError }
    } + +
    + + +
    + + ) } +
    + ); +} + +export const onboardingConnectRoute = createRoute( { + getParentRoute: () => onboardingLayoutRoute, + path: '/onboarding/connect', + component: OnboardingConnectPage, +} ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css new file mode 100644 index 0000000000..62cd347508 --- /dev/null +++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css @@ -0,0 +1,138 @@ +.signedOut { + display: flex; + flex-direction: column; + gap: 24px; +} + +.benefits { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.benefit { + display: flex; + align-items: center; + gap: 8px; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +.benefitIcon { + flex-shrink: 0; + fill: var(--wpds-color-fg-interactive-brand, #3858e9); +} + +.authActions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} + +.signupHint { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +.signupLink { + padding: 0; + height: auto; + min-height: 0; +} + +.offlineHint { + margin: 0; + font-size: 0.8125rem; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +.listHint { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +.siteList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 40vh; + overflow-y: auto; +} + +.siteItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 12px 16px; + border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); + border-radius: 8px; + background: var(--wpds-color-bg-surface-neutral-strong, #fff); + cursor: pointer; + text-align: left; + color: inherit; + transition: border-color 0.15s ease; +} + +.siteItem:hover { + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +.siteItemSelected { + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); + outline: 1px solid var(--wpds-color-stroke-interactive-brand, #3858e9); + outline-offset: -1px; +} + +.siteItemText { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.siteName { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteUrl { + font-size: 0.8125rem; + color: var(--wpds-color-fg-content-neutral-weak, #666); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteBadge { + flex-shrink: 0; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 8px; + border-radius: 9999px; + background: var(--wpds-color-bg-surface-neutral, #f0f0f0); + color: var(--wpds-color-fg-content-neutral-weak, #555); +} + +.submitError { + margin-top: 16px; + color: var(--wpds-color-fg-content-error, var(--wpds-color-fg-content-neutral)); +} + +.actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 24px; +} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx index 4632d6a7ed..0d36bb7bed 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx @@ -4,11 +4,13 @@ import { __ } from '@wordpress/i18n'; import { useCallback, useRef, useState } from 'react'; import { BuildNewSiteIllustration, + ConnectSiteIllustration, DropBackupIllustration, illustrationHostClass, StartFromBlueprintIllustration, } from '@/components/onboarding-illustrations'; import { useConnector } from '@/data/core'; +import { useOffline } from '@/hooks/use-offline'; import { isValidBackupFile } from '@/lib/backup-files'; import { setPendingBackup } from '@/lib/pending-backup'; import { onboardingLayoutRoute } from '../layout-onboarding'; @@ -16,6 +18,32 @@ import styles from './style.module.css'; const cardClass = `${ styles.card } ${ illustrationHostClass }`; +// Connecting requires WordPress.com, so the card is disabled offline — +// matching the desktop renderer's options screen. +function ConnectSiteCard() { + const isOffline = useOffline(); + return ( + { + if ( isOffline ) { + event.preventDefault(); + } + } } + > + +
    +

    { __( 'Connect a site' ) }

    +

    + { __( 'Edit a WordPress.com or Pressable site locally, then push changes back' ) } +

    +
    + + ); +} + /** * The import card doubles as a drop target, mirroring the desktop renderer's * options screen: dropping (or browsing to) a valid backup archive skips the @@ -122,6 +150,7 @@ function OnboardingHomePage() {

    + diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css index c0612ee16e..f27ceaad60 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css @@ -46,11 +46,31 @@ transition: border-color 0.15s ease; } +/* Illustrations are authored at 198×110; let them shrink with the card so + four cards fit comfortably on narrower windows. */ +.card svg { + max-width: 100%; + height: auto; +} + .card:hover, .cardDragging { border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); } +.cardDisabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cardDisabled:hover { + border-color: var(--wpds-color-stroke-surface-neutral, #ddd); +} + +.cardDisabled:hover .cardTitle { + color: inherit; +} + .cardDragging { background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand, #3858e9) 5%, transparent); } diff --git a/apps/ui/src/ui-classic/router/router.tsx b/apps/ui/src/ui-classic/router/router.tsx index 28c826f134..5d1af5e94a 100644 --- a/apps/ui/src/ui-classic/router/router.tsx +++ b/apps/ui/src/ui-classic/router/router.tsx @@ -7,6 +7,7 @@ import { dashboardRoute } from './route-dashboard'; import { indexRoute } from './route-index'; import { newSessionRoute } from './route-new-session'; import { onboardingBlueprintRoute } from './route-onboarding-blueprint'; +import { onboardingConnectRoute } from './route-onboarding-connect'; import { onboardingCreateRoute } from './route-onboarding-create'; import { onboardingHomeRoute } from './route-onboarding-home'; import { onboardingImportRoute } from './route-onboarding-import'; @@ -28,6 +29,7 @@ const routeTree = rootRoute.addChildren( [ onboardingHomeRoute, onboardingCreateRoute, onboardingBlueprintRoute, + onboardingConnectRoute, onboardingImportRoute, ] ), ] ); From 65fc8f55702db850ef62bffe367c3bdfb013cd54 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 13:01:51 -0400 Subject: [PATCH 06/24] apps/ui: announce site creation and collision-check seeded site names All four creation flows now announce ' site added.' to screen readers on success, matching the desktop renderer. Site names seeded from blueprints and backup filenames run through the shared find-available-name helper so a colliding name gets an incremented suffix instead of a path error the user has to resolve by hand. Co-Authored-By: Claude Fable 5 --- .../route-onboarding-blueprint/index.tsx | 53 ++++++++++++++++--- .../router/route-onboarding-connect/index.tsx | 10 +++- .../router/route-onboarding-create/index.tsx | 10 +++- .../router/route-onboarding-import/index.tsx | 45 ++++++++++++++-- 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx index feaaa89c34..34df9c5a19 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx @@ -3,14 +3,18 @@ import { updateBlueprintWithFormValues, } from '@studio/common/lib/blueprint-settings'; import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; import { arrowLeft } from '@wordpress/icons'; import { Button, Icon } from '@wordpress/ui'; import { useCallback, useEffect, useState } from 'react'; import { flushSync } from 'react-dom'; import { BlueprintSelector, type PickedBlueprint } from '@/components/blueprint-selector'; import { CreateSiteForm } from '@/components/create-site-form'; -import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; +import { + useExistingCustomDomains, + useFindAvailableSiteName, +} from '@/data/queries/use-create-site-helpers'; import { useFeaturedBlueprints } from '@/data/queries/use-featured-blueprints'; import { useCreateSite } from '@/data/queries/use-sites'; import { peekPendingBlueprint, clearPendingBlueprint } from '@/lib/pending-blueprint'; @@ -69,6 +73,28 @@ function OnboardingBlueprintPage() { } }, [ picked ] ); + // The blueprint's suggested site name can collide with an existing site + // folder; resolve an available variant ("Name", "Name 2", ...) before + // seeding the form, like the desktop renderer does. + const findAvailableSiteName = useFindAvailableSiteName(); + const [ seededName, setSeededName ] = useState< string | null >( null ); + useEffect( () => { + if ( ! picked ) { + setSeededName( null ); + return; + } + const rawName = extractFormValuesFromBlueprint( picked.blueprint ).siteName || picked.title; + let cancelled = false; + void findAvailableSiteName( rawName ).then( ( name ) => { + if ( ! cancelled ) { + setSeededName( name ); + } + } ); + return () => { + cancelled = true; + }; + }, [ picked, findAvailableSiteName ] ); + const handlePick = useCallback( ( blueprint: PickedBlueprint ) => { // `flushSync` commits the state updates *before* `navigate` fires so @@ -128,6 +154,13 @@ function OnboardingBlueprintPage() { filePath: picked.filePath, }, } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + values.name + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setSubmitError( @@ -161,10 +194,18 @@ function OnboardingBlueprintPage() { // above; render nothing in the intermediate frame to avoid a flash. if ( ! picked ) return null; - const initialValues = mapBlueprintSettingsToFormValues( - extractFormValuesFromBlueprint( picked.blueprint ), - picked.title - ); + // Hold the form until the collision-checked name resolves — initial + // values are applied once, so seeding early with a colliding name would + // lock it in. + const initialValues = seededName + ? { + ...mapBlueprintSettingsToFormValues( + extractFormValuesFromBlueprint( picked.blueprint ), + picked.title + ), + name: seededName, + } + : undefined; return (
    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 index fafb5d7aac..ffb2ebb708 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx @@ -1,5 +1,6 @@ import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; import { check, wordpress } from '@wordpress/icons'; import { Button, Icon } from '@wordpress/ui'; import { useCallback, useState } from 'react'; @@ -143,6 +144,13 @@ function OnboardingConnectPage() { // Fire-and-forget: the pull reports progress through the shared // sync-activity channel, which the site view surfaces. pullSiteFromLive.mutate( { siteId: site.id, remoteSiteId: selectedSite.id } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + availableName + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setIsConnecting( false ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx index bbb98abf50..c3756bff18 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx @@ -1,5 +1,6 @@ import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; import { useState } from 'react'; import { CreateSiteForm } from '@/components/create-site-form'; import { @@ -33,6 +34,13 @@ function CreateSitePage() { adminPassword: values.adminPassword || undefined, adminEmail: values.adminEmail || undefined, } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + values.name + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setSubmitError( diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx index b562549796..f068fba855 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx @@ -1,6 +1,7 @@ import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; import { arrowLeft, download } from '@wordpress/icons'; import { Button, Icon } from '@wordpress/ui'; import { useCallback, useEffect, useState } from 'react'; @@ -8,7 +9,10 @@ import { flushSync } from 'react-dom'; import { CreateSiteForm } from '@/components/create-site-form'; import { FileDropzone } from '@/components/file-dropzone'; import { useConnector } from '@/data/core'; -import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; +import { + useExistingCustomDomains, + useFindAvailableSiteName, +} from '@/data/queries/use-create-site-helpers'; import { useImportSite } from '@/data/queries/use-import-site'; import { useCreateSite } from '@/data/queries/use-sites'; import { isValidBackupFile, nameFromFilename } from '@/lib/backup-files'; @@ -76,6 +80,27 @@ function OnboardingImportPage() { } }, [ picked ] ); + // The filename-derived site name can collide with an existing site + // folder; resolve an available variant ("Name", "Name 2", ...) before + // seeding the form, like the desktop renderer does. + const findAvailableSiteName = useFindAvailableSiteName(); + const [ seededName, setSeededName ] = useState< string | null >( null ); + useEffect( () => { + if ( ! picked ) { + setSeededName( null ); + return; + } + let cancelled = false; + void findAvailableSiteName( nameFromFilename( picked.file.name ) ).then( ( name ) => { + if ( ! cancelled ) { + setSeededName( name ); + } + } ); + return () => { + cancelled = true; + }; + }, [ picked, findAvailableSiteName ] ); + const handlePick = useCallback( async ( file: File ) => { if ( ! isValidBackupFile( file ) ) { @@ -142,6 +167,13 @@ function OnboardingImportPage() { siteId: site.id, backup: { path: picked.path, type: picked.file.type }, } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + values.name + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setSubmitError( @@ -176,9 +208,12 @@ function OnboardingImportPage() { // render nothing in the intermediate frame to avoid a flash. if ( ! picked ) return null; - const initialValues: Partial< CreateSiteFormValues > = { - name: nameFromFilename( picked.file.name ), - }; + // Hold the form until the collision-checked name resolves — initial + // values are applied once, so seeding early with a colliding name would + // lock it in. + const initialValues: Partial< CreateSiteFormValues > | undefined = seededName + ? { name: seededName } + : undefined; const isSubmitting = createSite.isPending || importSite.isPending; return ( From 3a12e588560075b17704b500e867b8372603721f Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 13:11:02 -0400 Subject: [PATCH 07/24] apps/ui: fix illustration sizing and onboarding overflow found in visual review The classic UI's compact-density rule squashes every SVG to 16px icon size, collapsing the onboarding illustrations and the Empty Site glyph; add a data-keep-size escape hatch that defers back to the SVG's own width/height attributes. The onboarding layout also clipped the top of content taller than the viewport (the full blueprint gallery) because it centered with flex justify; switch to auto block margins inside a scrollable container so short pages stay centered and tall ones scroll. Co-Authored-By: Claude Fable 5 --- apps/ui/src/components/blueprint-selector/index.tsx | 1 + apps/ui/src/components/onboarding-illustrations/index.tsx | 4 ++++ apps/ui/src/components/onboarding-layout/index.tsx | 2 +- apps/ui/src/components/onboarding-layout/style.module.css | 6 ++++++ apps/ui/src/index.css | 8 ++++++++ 5 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/components/blueprint-selector/index.tsx b/apps/ui/src/components/blueprint-selector/index.tsx index 232e8db50f..3fa2c3fc27 100644 --- a/apps/ui/src/components/blueprint-selector/index.tsx +++ b/apps/ui/src/components/blueprint-selector/index.tsx @@ -84,6 +84,7 @@ function EmptySiteCard( { onPick }: { onPick: () => void } ) { fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" + data-keep-size >
    ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx index f068fba855..f84d057d83 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx @@ -2,12 +2,13 @@ import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; import { createRoute, useNavigate } from '@tanstack/react-router'; import { speak } from '@wordpress/a11y'; import { __, sprintf } from '@wordpress/i18n'; -import { arrowLeft, download } from '@wordpress/icons'; -import { Button, Icon } from '@wordpress/ui'; +import { download } from '@wordpress/icons'; +import { Button } from '@wordpress/ui'; import { useCallback, useEffect, useState } from 'react'; import { flushSync } from 'react-dom'; import { CreateSiteForm } from '@/components/create-site-form'; import { FileDropzone } from '@/components/file-dropzone'; +import { OnboardingFooter } from '@/components/onboarding-footer'; import { useConnector } from '@/data/core'; import { useExistingCustomDomains, @@ -19,7 +20,6 @@ import { isValidBackupFile, nameFromFilename } from '@/lib/backup-files'; import { clearPendingBackup, peekPendingBackup } from '@/lib/pending-backup'; import { onboardingLayoutRoute } from '../layout-onboarding'; import sharedStyles from '../layout-onboarding/style.module.css'; -import styles from './style.module.css'; import type { CreateSiteFormValues } from '@/components/create-site-form'; type Step = 'select' | 'configure'; @@ -200,6 +200,16 @@ function OnboardingImportPage() { onClear={ handleClearPick } error={ pickError } /> + + + ); } @@ -218,16 +228,6 @@ function OnboardingImportPage() { return (
    -

    { __( 'Configure the imported site' ) }

    { __( 'Pick a name and local folder. The backup will restore on top of this new site.' ) } @@ -236,10 +236,12 @@ function OnboardingImportPage() { initialValues={ initialValues } existingDomainNames={ existingDomainNames ?? [] } onSubmit={ handleSubmit } - onCancel={ () => void navigate( { to: '/onboarding' } ) } + onCancel={ handleBackToSelect } + cancelLabel={ __( 'Back' ) } isSubmitting={ isSubmitting } submitError={ submitError } submitLabel={ __( 'Import site' ) } + actionsPlacement="floating" />

    ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css deleted file mode 100644 index 56615bfa21..0000000000 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.backLink { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin-bottom: 16px; - padding: 4px 8px 4px 4px; -} diff --git a/apps/ui/src/ui-classic/router/router.tsx b/apps/ui/src/ui-classic/router/router.tsx index 5d1af5e94a..b0e15dfa88 100644 --- a/apps/ui/src/ui-classic/router/router.tsx +++ b/apps/ui/src/ui-classic/router/router.tsx @@ -39,6 +39,9 @@ export function createAppRouter( context: RouterContext ) { routeTree, context, defaultPreload: 'intent', + // Animate route (and step) changes with the View Transitions API — + // the fade-and-rise keyframes live in index.css. + defaultViewTransition: true, history: createPackagedRouterHistory(), } ); } From 392ba28f0cae5affe6e37a261a795377e27a7b1d Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 14:00:25 -0400 Subject: [PATCH 09/24] =?UTF-8?q?apps/ui:=20onboarding=20review=20round=20?= =?UTF-8?q?two=20=E2=80=94=20three=20flow=20cards,=20dark-mode=20form=20fi?= =?UTF-8?q?xes,=20full=20connect=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The flow picker is back to three options: Build a new site now leads to the gallery (retitled 'Build a new site'), where Empty site hands off to the create form — blueprints are a choice within building, not a top-level path. The standalone blueprint card and its illustration are gone, and the create form backs into the gallery. - The create form's local-path field and inline errors used hardcoded light colors; they now follow the themed --wp-components vars like their sibling inputs. Input prefix icons (the email envelope) inherit the foreground instead of defaulting to black, and unchecked checkboxes use the themed input background instead of glowing white in dark mode. - Connect a site lists every site on the account — non-syncable ones stay visible but disabled with a status label (Needs upgrade, Already connected, Unsupported...) instead of being silently filtered out, which made the list look mysteriously short. Co-Authored-By: Claude Fable 5 --- .../create-site-form/style.module.css | 30 ++++---- .../onboarding-illustrations/index.tsx | 49 ------------- .../components/onboarding-layout/index.tsx | 15 ++-- .../onboarding-layout/style.module.css | 6 -- apps/ui/src/index.css | 15 ++++ .../router/layout-onboarding/index.tsx | 2 +- .../route-onboarding-blueprint/index.tsx | 6 +- .../router/route-onboarding-connect/index.tsx | 68 +++++++++++++++---- .../route-onboarding-connect/style.module.css | 12 ++++ .../router/route-onboarding-create/index.tsx | 2 +- .../router/route-onboarding-home/index.tsx | 14 +--- 11 files changed, 107 insertions(+), 112 deletions(-) diff --git a/apps/ui/src/components/create-site-form/style.module.css b/apps/ui/src/components/create-site-form/style.module.css index ef5a6793d3..20867dee42 100644 --- a/apps/ui/src/components/create-site-form/style.module.css +++ b/apps/ui/src/components/create-site-form/style.module.css @@ -18,10 +18,10 @@ width: 100%; min-height: 40px; padding: 0 4px 0 12px; - border: 1px solid #8a8a8a; + border: 1px solid var(--wp-components-color-gray-600, #8a8a8a); border-radius: 2px; - background: #fff; - color: #1e1e1e; + background: var(--wp-components-color-background, #fff); + color: var(--wp-components-color-foreground, #1e1e1e); font: inherit; font-size: 16px; text-align: left; @@ -30,22 +30,22 @@ } .pathTrigger:hover { - border-color: #1e1e1e; + border-color: var(--wp-components-color-foreground, #1e1e1e); } .pathTrigger:focus-visible { - border-color: #1e1e1e; - box-shadow: 0 0 0 0.5px #1e1e1e; + border-color: var(--wp-components-color-foreground, #1e1e1e); + box-shadow: 0 0 0 0.5px var(--wp-components-color-foreground, #1e1e1e); outline: none; } .pathTriggerError { - border-color: #d63638; + border-color: var(--wpds-color-fg-content-error, #d63638); } .pathTriggerError:focus-visible { - border-color: #d63638; - box-shadow: 0 0 0 0.5px #d63638; + border-color: var(--wpds-color-fg-content-error, #d63638); + box-shadow: 0 0 0 0.5px var(--wpds-color-fg-content-error, #d63638); } .pathValue { @@ -57,7 +57,7 @@ } .pathValuePlaceholder { - color: #757575; + color: var(--wp-components-color-gray-700, #757575); } .pathTriggerAction { @@ -65,11 +65,11 @@ padding: 6px 12px; font-size: 13px; font-weight: 500; - color: #1e1e1e; + color: var(--wp-components-color-foreground, #1e1e1e); } .pathErrorHelp { - color: #d63638; + color: var(--wpds-color-fg-content-error, #d63638); } .advancedToggle { @@ -83,14 +83,14 @@ margin-left: 8px; font-size: 12px; font-weight: 500; - color: #d63638; + color: var(--wpds-color-fg-content-error, #d63638); } .submitError { padding: 10px 12px; border-radius: 4px; - background: #fce8e8; - color: #7a1a1a; + background: color-mix(in srgb, var(--wpds-color-fg-content-error, #d63638) 12%, transparent); + color: var(--wpds-color-fg-content-error, #7a1a1a); font-size: 13px; } diff --git a/apps/ui/src/components/onboarding-illustrations/index.tsx b/apps/ui/src/components/onboarding-illustrations/index.tsx index 400c6f0730..a0a9de5718 100644 --- a/apps/ui/src/components/onboarding-illustrations/index.tsx +++ b/apps/ui/src/components/onboarding-illustrations/index.tsx @@ -59,55 +59,6 @@ export function BuildNewSiteIllustration() { ); } -export function StartFromBlueprintIllustration() { - return ( - - ); -} - export function ConnectSiteIllustration() { return ( ) } -
    { children }
    +
    + { children } +
    ); } diff --git a/apps/ui/src/components/onboarding-layout/style.module.css b/apps/ui/src/components/onboarding-layout/style.module.css index a1b925af94..383da1aa79 100644 --- a/apps/ui/src/components/onboarding-layout/style.module.css +++ b/apps/ui/src/components/onboarding-layout/style.module.css @@ -21,12 +21,6 @@ max-width: 820px; } -/* `extra-wide` variant — fits the onboarding home's four flow-picker cards - side by side. */ -.contentExtraWide { - max-width: 1040px; -} - .close { position: absolute; top: 16px; diff --git a/apps/ui/src/index.css b/apps/ui/src/index.css index 15149303f6..cbe3f68aa1 100644 --- a/apps/ui/src/index.css +++ b/apps/ui/src/index.css @@ -199,6 +199,21 @@ a:focus:not( :focus-visible ), background-color: var( --wp-components-color-background, #fff ); } +/* Input prefix icons (e.g. the email field's envelope) render with the SVG + default black fill; inherit the input's foreground so they track dark + mode. */ +.components-input-control-prefix-wrapper svg { + fill: currentColor; +} + +/* CheckboxControl hardcodes a white box; defer to the themed input + background so unchecked boxes don't glow in dark mode. Specificity beats + the emotion-generated class that ships the hardcoded white; scoped to + :not(:checked) so the checked state keeps its accent fill. */ +input.components-checkbox-control__input[type='checkbox']:not(:checked) { + background: var( --wp-components-color-background, #fff ); +} + /* Honor the user's reduced-motion preference across the entire app. Class- scoped transitions out-specify a `*`-targeted rule, so `!important` is needed to clamp them. Components that genuinely need to keep motion can 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 e8c12ae986..c83dc89af3 100644 --- a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx +++ b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx @@ -40,7 +40,7 @@ function OnboardingShell() { return ( void navigate( { to: '/dashboard' } ) : undefined } - width={ isHome ? 'extra-wide' : isWide ? 'wide' : 'default' } + width={ isWide ? 'wide' : 'default' } background={ dotGrid } > diff --git a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx index c96294f860..f2ad441b6f 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx @@ -173,11 +173,9 @@ function OnboardingBlueprintPage() { if ( activeStep === 'select' ) { return (
    -

    { __( 'Start from a Blueprint' ) }

    +

    { __( 'Build a new site' ) }

    - { __( - 'Pick a featured Blueprint or drop in your own to provision plugins, content, and settings.' - ) } + { __( 'Start with an empty site or choose a template.' ) }

    void; } ) { + const isSyncable = site.syncSupport === 'syncable'; + const statusLabel = getSyncStatusLabel( site ); + const cardClass = isSelected + ? `${ styles.siteCard } ${ styles.siteCardSelected }` + : isSyncable + ? styles.siteCard + : `${ styles.siteCard } ${ styles.siteCardDisabled }`; return (
  • @@ -122,7 +154,7 @@ function OnboardingConnectPage() { const navigate = useNavigate(); const connector = useConnector(); const { data: user, isLoading: isAuthLoading } = useAuthUser(); - const pickable = usePickableWpcomSites( { enabled: !! user } ); + const syncable = useSyncableWpcomSites( { enabled: !! user } ); const createSite = useCreateSite(); const pullSiteFromLive = usePullSiteFromLive(); const findAvailableSiteName = useFindAvailableSiteName(); @@ -132,7 +164,15 @@ function OnboardingConnectPage() { const [ submitError, setSubmitError ] = useState( '' ); const [ searchQuery, setSearchQuery ] = useState( '' ); - const sites = useMemo( () => pickable.data ?? [], [ pickable.data ] ); + // Show every site on the account — syncable ones first, the rest visible + // but disabled with a status label, matching the desktop site picker. + const sites = useMemo( () => { + const all = syncable.data ?? []; + return [ + ...all.filter( ( site ) => site.syncSupport === 'syncable' ), + ...all.filter( ( site ) => site.syncSupport !== 'syncable' ), + ]; + }, [ syncable.data ] ); const filteredSites = useMemo( () => { const query = searchQuery.toLowerCase().trim(); if ( ! query ) { @@ -224,15 +264,15 @@ function OnboardingConnectPage() { />
    ) } - { pickable.isLoading && ( + { syncable.isLoading && (

    { __( 'Loading your sites…' ) }

    ) } - { ! pickable.isLoading && sites.length === 0 && ( + { ! syncable.isLoading && sites.length === 0 && (

    - { __( 'No syncable sites found on your WordPress.com account.' ) } + { __( 'No WordPress.com sites found on this account.' ) }

    ) } - { ! pickable.isLoading && sites.length > 0 && filteredSites.length === 0 && ( + { ! syncable.isLoading && sites.length > 0 && filteredSites.length === 0 && (

    { sprintf( // translators: %s is the search query. @@ -274,10 +314,10 @@ function OnboardingConnectPage() { type="button" variant="minimal" tone="neutral" - onClick={ () => void pickable.refetch() } - disabled={ pickable.isFetching || isConnecting } + onClick={ () => void syncable.refetch() } + disabled={ syncable.isFetching || isConnecting } > - { pickable.isFetching ? __( 'Refreshing…' ) : __( 'Refresh sites' ) } + { syncable.isFetching ? __( 'Refreshing…' ) : __( 'Refresh sites' ) } + { site.planName && { site.planName } } + + ); + } + if ( site.syncSupport === 'needs-transfer' ) { + return ( +

    + +
    + ); + } + return null; +} + function RemoteSiteCard( { site, isSelected, @@ -108,14 +240,25 @@ function RemoteSiteCard( { onSelect: ( id: number ) => void; } ) { const isSyncable = site.syncSupport === 'syncable'; + const isDimmed = + site.syncSupport === 'deleted' || + site.syncSupport === 'unsupported' || + site.syncSupport === 'missing-permissions'; const statusLabel = getSyncStatusLabel( site ); - const cardClass = isSelected - ? `${ styles.siteCard } ${ styles.siteCardSelected }` - : isSyncable - ? styles.siteCard - : `${ styles.siteCard } ${ styles.siteCardDisabled }`; + const environment = getSiteEnvironment( site ); + + let cardClass = styles.siteCard; + if ( isSelected ) { + cardClass += ` ${ styles.siteCardSelected }`; + } else if ( ! isSyncable ) { + cardClass += ` ${ styles.siteCardInert }`; + if ( isDimmed ) { + cardClass += ` ${ styles.siteCardDimmed }`; + } + } + return ( -
  • +
  • +
  • ); } @@ -153,6 +297,7 @@ function RemoteSiteCard( { function OnboardingConnectPage() { const navigate = useNavigate(); const connector = useConnector(); + const locale = useUserLocale(); const { data: user, isLoading: isAuthLoading } = useAuthUser(); const syncable = useSyncableWpcomSites( { enabled: !! user } ); const createSite = useCreateSite(); @@ -164,15 +309,7 @@ function OnboardingConnectPage() { const [ submitError, setSubmitError ] = useState( '' ); const [ searchQuery, setSearchQuery ] = useState( '' ); - // Show every site on the account — syncable ones first, the rest visible - // but disabled with a status label, matching the desktop site picker. - const sites = useMemo( () => { - const all = syncable.data ?? []; - return [ - ...all.filter( ( site ) => site.syncSupport === 'syncable' ), - ...all.filter( ( site ) => site.syncSupport !== 'syncable' ), - ]; - }, [ syncable.data ] ); + const sites = useMemo( () => syncable.data ?? [], [ syncable.data ] ); const filteredSites = useMemo( () => { const query = searchQuery.toLowerCase().trim(); if ( ! query ) { @@ -183,6 +320,7 @@ function OnboardingConnectPage() { site.name.toLowerCase().includes( query ) || site.url.toLowerCase().includes( query ) ); }, [ sites, searchQuery ] ); + const sections = useMemo( () => groupSites( filteredSites ), [ filteredSites ] ); const selectedSite = sites.find( ( site ) => site.id === selectedId ); const showSearch = searchQuery.length > 0 || sites.length > SEARCH_VISIBILITY_THRESHOLD; @@ -252,8 +390,8 @@ function OnboardingConnectPage() { { isSignedIn && ( <> - { showSearch && ( -
    +
    + { showSearch && ( -
    - ) } + ) } +

    + + { ' · ' } + +

    +
    + { syncable.isLoading && (

    { __( 'Loading your sites…' ) }

    ) } @@ -281,18 +447,31 @@ function OnboardingConnectPage() { ) }

    ) } - { filteredSites.length > 0 && ( -
      - { filteredSites.map( ( site ) => ( - - ) ) } -
    - ) } + +
    + { sections.map( ( section ) => ( +
    + { section.title && ( +
    +

    { section.title }

    + { section.description && ( +

    { section.description }

    + ) } +
    + ) } +
      + { section.sites.map( ( site ) => ( + + ) ) } +
    +
    + ) ) } +
    { submitError &&
    { submitError }
    } @@ -309,29 +488,18 @@ function OnboardingConnectPage() { { __( 'Back' ) } { isSignedIn && ( - <> - - - + ) } diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css index a95b31f618..33481670fa 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css +++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css @@ -57,14 +57,66 @@ color: var(--wpds-color-fg-content-neutral-weak, #666); } -.toolbar { +/* Search + helper links, centered under the subtitle like the desktop + renderer's picker header. */ +.searchHeader { display: flex; - justify-content: flex-end; - margin-bottom: 16px; + flex-direction: column; + align-items: center; + gap: 8px; + max-width: 480px; + margin: 0 auto 24px; } .search { - width: 200px; + width: 100%; +} + +.helperLinks { + margin: 0; + font-size: 0.75rem; + color: var(--wpds-color-fg-content-neutral-weak, #666); + display: flex; + align-items: center; + gap: 4px; +} + +.helperLink { + padding: 0; + height: auto; + min-height: 0; + font-size: 0.75rem; +} + +.sections { + display: flex; + flex-direction: column; + gap: 32px; + text-align: left; +} + +.section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sectionHeader { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sectionTitle { + font-size: 1rem; + font-weight: 500; + margin: 0; +} + +.sectionDescription { + margin: 0; + font-size: 0.875rem; + color: var(--wpds-color-fg-content-neutral-weak, #666); } /* Mirrors the desktop renderer's pull-remote picker: a responsive grid of @@ -74,9 +126,12 @@ margin: 0; padding: 0; display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; - text-align: left; +} + +.siteCardWrapper { + position: relative; } .siteCard { @@ -97,13 +152,19 @@ background: var(--wpds-color-bg-surface-neutral-strong, #f7f7f7); } -.siteCardDisabled, -.siteCardDisabled:hover { - opacity: 0.55; +/* Visible but not selectable (already connected, needs upgrade/transfer). */ +.siteCardInert, +.siteCardInert:hover { cursor: default; background: transparent; } +/* Sites that can't sync at all (deleted, unsupported, missing permissions). */ +.siteCardDimmed, +.siteCardDimmed:hover { + opacity: 0.55; +} + .siteCardSelected, .siteCardSelected:hover { box-shadow: 0 0 0 2px var(--wpds-color-stroke-interactive-brand, #3858e9); @@ -145,9 +206,79 @@ backdrop-filter: blur(4px); } -.siteBadgeStaging { - background: rgba(255, 255, 255, 0.9); +/* Environment chips use the same fixed palette as the desktop renderer's + EnvironmentBadge — identical in both color schemes by design. */ +.envBadge { + font-size: 11px; + line-height: 1; + padding: 4px 6px; + border-radius: 4px; + font-weight: 500; +} + +.envBadge-production { + background: #ceead6; + color: #1a6928; +} + +.envBadge-staging { + background: #fef0c7; + color: #93590c; +} + +.envBadge-development { + background: #d9e2ff; + color: #1f3a93; +} + +/* Centered CTA over a non-syncable site's thumbnail. Sits outside the inert + card button (siblings, not nested) so the markup stays valid; aligned to + the thumbnail box, which is inset by the card's 6px padding. */ +.thumbCtaOverlay { + position: absolute; + top: 6px; + left: 6px; + right: 6px; + aspect-ratio: 3 / 2; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + background: rgba(0, 0, 0, 0.2); +} + +.ctaButton { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border: none; + border-radius: 4px; + background: #fff; color: #1e1e1e; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.ctaButton:hover { + background: #f0f0f0; +} + +.planBadge { + font-size: 11px; + line-height: 1; + padding: 4px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + backdrop-filter: blur(4px); } .siteText { diff --git a/tools/common/lib/sync/sync-api.ts b/tools/common/lib/sync/sync-api.ts index a81b5a17fd..530c0672c2 100644 --- a/tools/common/lib/sync/sync-api.ts +++ b/tools/common/lib/sync/sync-api.ts @@ -33,21 +33,38 @@ const SITE_FIELDS = [ export async function fetchSyncableSites( token: string ): Promise< SyncSite[] > { const wpcom = wpcomFactory( token, wpcomXhrRequest ); - const rawResponse = await wpcom.req.get( - { - apiNamespace: 'rest/v1.2', - path: '/me/sites', - }, - { - fields: SITE_FIELDS, - filter: 'atomic,wpcom', - options: 'created_at,wpcom_staging_blog_ids', - site_activity: 'active', + // Mirrors the desktop renderer's site-picker query (wpcomSitesApi), but + // drains every page so callers get the full account in one call — the + // unpaginated v1.2 endpoint silently returned only a subset of sites. + const PER_PAGE = 100; + const MAX_PAGES = 20; + const allSites: unknown[] = []; + + for ( let page = 1; page <= MAX_PAGES; page++ ) { + const rawResponse = await wpcom.req.get( + { + apiNamespace: 'rest/v1.3', + path: '/me/sites', + }, + { + fields: SITE_FIELDS, + filter: 'atomic,wpcom', + options: 'created_at,wpcom_staging_blog_ids,software_version', + site_activity: 'active', + include_a8c_owned: false, + page, + per_page: PER_PAGE, + } + ); + + const parsed = sitesEndpointResponseSchema.parse( rawResponse ); + allSites.push( ...parsed.sites ); + if ( parsed.sites.length < PER_PAGE ) { + break; } - ); + } - const parsed = sitesEndpointResponseSchema.parse( rawResponse ); - return transformSitesResponse( parsed.sites ); + return transformSitesResponse( allSites ); } export async function initiateBackup( From 2c7bd91479d12c61503cf026f095640e0812f782 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 14:37:53 -0400 Subject: [PATCH 11/24] apps/ui: tidy the Build a new site gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds breathing room above the heading, fits all four starter cards (Empty site + the curated trio) on a single row — folding to a 2x2 grid on narrow windows — and collapses Explore more blueprints behind an accordion toggle, closed by default with the search field appearing on expand. With the long tail tucked away, the whole screen fits in one viewport. Grid pages get a little more width (820 to 960) to keep the four-up cards comfortable. Co-Authored-By: Claude Fable 5 --- .../components/blueprint-selector/index.tsx | 60 +++++++++++-------- .../blueprint-selector/style.module.css | 23 +++++++ .../onboarding-layout/style.module.css | 2 +- .../router/layout-onboarding/style.module.css | 6 ++ .../route-onboarding-blueprint/index.tsx | 2 +- 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/apps/ui/src/components/blueprint-selector/index.tsx b/apps/ui/src/components/blueprint-selector/index.tsx index 3fa2c3fc27..0e643df88b 100644 --- a/apps/ui/src/components/blueprint-selector/index.tsx +++ b/apps/ui/src/components/blueprint-selector/index.tsx @@ -7,7 +7,7 @@ import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprin import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; import { SearchControl, Spinner } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; -import { external } from '@wordpress/icons'; +import { chevronDown, chevronRight, external } from '@wordpress/icons'; import { Icon } from '@wordpress/ui'; import { useCallback, useMemo, useState } from 'react'; import { FileDropzone } from '@/components/file-dropzone'; @@ -152,6 +152,7 @@ export function BlueprintSelector( { const connector = useConnector(); const [ uploadError, setUploadError ] = useState< string | null >( null ); const [ searchQuery, setSearchQuery ] = useState( '' ); + const [ isExploreOpen, setIsExploreOpen ] = useState( false ); // The endpoint returns blueprints oldest-first; newest-first reads better // in the Explore grid (matches the desktop renderer). @@ -308,7 +309,7 @@ export function BlueprintSelector( { return (
    -
      +
        { isLoading && (
      • @@ -327,29 +328,40 @@ export function BlueprintSelector( { { exploreBlueprints.length > 0 && (
        -

        { __( 'Explore more blueprints' ) }

        - + + { isExploreOpen && ( + + ) }
        - { filteredExploreBlueprints.length === 0 ? ( -

        { __( 'No blueprints found.' ) }

        - ) : ( -
          - { filteredExploreBlueprints.map( ( item ) => ( - - ) ) } -
        - ) } + { isExploreOpen && + ( filteredExploreBlueprints.length === 0 ? ( +

        { __( 'No blueprints found.' ) }

        + ) : ( +
          + { filteredExploreBlueprints.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 25e3dbbd1a..1d0d3f5750 100644 --- a/apps/ui/src/components/blueprint-selector/style.module.css +++ b/apps/ui/src/components/blueprint-selector/style.module.css @@ -50,6 +50,29 @@ } } +/* 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)); + } +} + +.accordionToggle { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: none; + background: none; + color: inherit; + cursor: pointer; +} + .gridStatus { display: flex; align-items: center; diff --git a/apps/ui/src/components/onboarding-layout/style.module.css b/apps/ui/src/components/onboarding-layout/style.module.css index 383da1aa79..8b83c05feb 100644 --- a/apps/ui/src/components/onboarding-layout/style.module.css +++ b/apps/ui/src/components/onboarding-layout/style.module.css @@ -18,7 +18,7 @@ /* `wide` variant — used by content-heavy onboarding pages like the blueprint selector. Sized to fit a 3-column card grid (see `blueprint-selector`). */ .contentWide { - max-width: 820px; + max-width: 960px; } .close { 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 3913ffd835..f8134a35e3 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 @@ -5,6 +5,12 @@ padding-bottom: 72px; } +/* Tall, scrolling pages (the blueprint gallery) sit flush against the + viewport top once they overflow; give the heading room to breathe. */ +.pageSpacious { + padding-top: 48px; +} + /* Forms read better as a narrow left-aligned column centered in the viewport — matches the desktop renderer's Add Site form layout. */ .page form { diff --git a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx index f2ad441b6f..b434bab8da 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx @@ -172,7 +172,7 @@ function OnboardingBlueprintPage() { if ( activeStep === 'select' ) { return ( -
        +

        { __( 'Build a new site' ) }

        { __( 'Start with an empty site or choose a template.' ) } From 0fc61719fafc1d2b902ae5bbb37ce32bfa8c6979 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 14:47:33 -0400 Subject: [PATCH 12/24] =?UTF-8?q?apps/ui:=20gallery=20polish=20=E2=80=94?= =?UTF-8?q?=20eye=20preview=20buttons,=20upload=20joins=20the=20accordion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Live Preview pill becomes a compact eye-icon button with a 'View a preview in your browser' tooltip, matching how previews read elsewhere. Upload your own moves inside the Explore more blueprints accordion since it belongs to the blueprints section, and the accordion now animates open and closed (a 0fr-to-1fr grid-row slide with a fade; content stays mounted and inert while collapsed so both directions animate). Co-Authored-By: Claude Fable 5 --- .../components/blueprint-selector/index.tsx | 144 ++++++++++-------- .../blueprint-selector/style.module.css | 42 ++++- 2 files changed, 123 insertions(+), 63 deletions(-) diff --git a/apps/ui/src/components/blueprint-selector/index.tsx b/apps/ui/src/components/blueprint-selector/index.tsx index 0e643df88b..de1aefdae5 100644 --- a/apps/ui/src/components/blueprint-selector/index.tsx +++ b/apps/ui/src/components/blueprint-selector/index.tsx @@ -7,8 +7,8 @@ import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprin import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; import { SearchControl, Spinner } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; -import { chevronDown, chevronRight, external } from '@wordpress/icons'; -import { Icon } from '@wordpress/ui'; +import { chevronDown, chevronRight, seen } from '@wordpress/icons'; +import { Icon, Tooltip } from '@wordpress/ui'; import { useCallback, useMemo, useState } from 'react'; import { FileDropzone } from '@/components/file-dropzone'; import { useConnector } from '@/data/core'; @@ -45,27 +45,38 @@ function getBlueprintCategories( blueprint: FeaturedBlueprint ): string[] { } /** - * "Live Preview" pill overlaid on a card's image. Rendered as a sibling of + * 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 (

        - + + + void connector.openExternalUrl( url ) } + aria-label={ sprintf( + // translators: %s is the blueprint title. + __( 'Preview %s in Playground' ), + title + ) } + > + + + } + /> + }> + { label } + + +
        ); } @@ -325,54 +336,63 @@ export function BlueprintSelector( {
    - { exploreBlueprints.length > 0 && ( -
    -
    - - { isExploreOpen && ( - +
    + + { isExploreOpen && ( + + ) } +
    + { /* Animated collapse: the 0fr -> 1fr grid row transition slides + the content open without measuring heights. Content stays + mounted (inert while closed) so closing animates too. */ } +
    +
    + { exploreBlueprints.length > 0 && + ( filteredExploreBlueprints.length === 0 ? ( +

    { __( 'No blueprints found.' ) }

    + ) : ( +
      + { filteredExploreBlueprints.map( ( item ) => ( + + ) ) } +
    + ) ) } +
    +

    { __( 'Upload your own' ) }

    + void handleFile( file ) } + error={ uploadError } /> - ) } +
    - { isExploreOpen && - ( filteredExploreBlueprints.length === 0 ? ( -

    { __( 'No blueprints found.' ) }

    - ) : ( -
      - { filteredExploreBlueprints.map( ( item ) => ( - - ) ) } -
    - ) ) } -
    - ) } - -
    -

    { __( 'Upload your own' ) }

    - void handleFile( file ) } - error={ uploadError } - /> +
    ); diff --git a/apps/ui/src/components/blueprint-selector/style.module.css b/apps/ui/src/components/blueprint-selector/style.module.css index 1d0d3f5750..1e7fd2b2c6 100644 --- a/apps/ui/src/components/blueprint-selector/style.module.css +++ b/apps/ui/src/components/blueprint-selector/style.module.css @@ -73,6 +73,34 @@ cursor: pointer; } +/* Expand/collapse without measuring heights: animating the grid row from + 0fr to 1fr slides the content open while the inner wrapper clips it. */ +.collapse { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: grid-template-rows 0.3s ease, opacity 0.25s ease; +} + +.collapseOpen { + grid-template-rows: 1fr; + opacity: 1; +} + +.collapseInner { + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + gap: 32px; +} + +.uploadSection { + display: flex; + flex-direction: column; + gap: 12px; +} + .gridStatus { display: flex; align-items: center; @@ -126,7 +154,7 @@ align-items: center; gap: 4px; padding: 4px 8px; - border: 1px solid rgba(0, 0, 0, 0.08); + border: 1px solid rgba(255, 255, 255, 0.9); border-radius: 4px; background: rgba(255, 255, 255, 0.92); color: #1e1e1e; @@ -135,6 +163,18 @@ white-space: nowrap; cursor: pointer; backdrop-filter: blur(6px); + /* 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:hover { From 5a039310bda1f71102001ccae89028cb1f4b0fd2 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 14:52:27 -0400 Subject: [PATCH 13/24] apps/ui: stack the Add a site cards on narrow windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below 900px the three option cards stack vertically and each switches to a horizontal layout — illustration on the left, text on the right — instead of squeezing three columns. Co-Authored-By: Claude Fable 5 --- .../route-onboarding-home/style.module.css | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css index f27ceaad60..68b76905c7 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css @@ -105,6 +105,30 @@ color: var(--wpds-color-fg-content-error, #b32d2e); } +/* Narrow windows: stack the option cards and lay each one out + horizontally — illustration on the left, text on the right. */ +@media (max-width: 900px) { + .cards { + flex-direction: column; + align-items: stretch; + } + + .card { + flex-direction: row; + align-items: center; + gap: 20px; + text-align: left; + } + + .card svg { + flex-shrink: 0; + } + + .cardText { + text-align: left; + } +} + .hiddenInput { display: none; } From ee7c0fce6142e3c516158179f8a7230d7b7f4e62 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 10 Jun 2026 14:54:39 -0400 Subject: [PATCH 14/24] apps/ui: drag the window from the onboarding edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site-creation flow fills the window with no title bar, leaving nothing to drag the window by. Invisible strips along the top, left, and bottom edges now act as the drag region — a phantom title bar without the chrome. A full-background drag region would have been simpler but the window manager consumes mouse events over drag areas, which froze the interactive dot grid; the strips keep the background alive for it. The right edge stays free for the scroll bar, and interactive elements overlapping a strip (the close button) remain clickable via the global no-drag rule. Co-Authored-By: Claude Fable 5 --- .../components/onboarding-layout/index.tsx | 5 +++ .../onboarding-layout/style.module.css | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/apps/ui/src/components/onboarding-layout/index.tsx b/apps/ui/src/components/onboarding-layout/index.tsx index 08ee0434d3..0c1a1773b4 100644 --- a/apps/ui/src/components/onboarding-layout/index.tsx +++ b/apps/ui/src/components/onboarding-layout/index.tsx @@ -35,6 +35,11 @@ export function OnboardingLayout( { return ( { background } +