From 44a3e20de42364d8fa57b6df64cf035e8a7d8485 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 24 Jul 2026 13:58:08 +0100 Subject: [PATCH 1/7] Add a first-run orientation guide, replayable from Help --- apps/studio/src/ipc-handlers.ts | 2 + apps/studio/src/ipc-utils.ts | 1 + apps/studio/src/menu.ts | 7 + .../modules/user-settings/lib/ipc-handlers.ts | 35 +++++ apps/studio/src/preload.ts | 2 + apps/studio/src/storage/storage-types.ts | 14 ++ apps/studio/src/storage/user-data.ts | 1 + apps/ui/src/app/app-providers.tsx | 5 +- .../onboarding-guide/illustrations.tsx | 9 ++ .../src/components/onboarding-guide/index.tsx | 84 ++++++++++++ .../onboarding-guide/style.module.css | 108 +++++++++++++++ .../onboarding-guide/use-onboarding-guide.tsx | 64 +++++++++ .../src/data/core/connectors/hosted/index.ts | 34 +++++ apps/ui/src/data/core/connectors/ipc/index.ts | 14 ++ .../src/data/core/connectors/local/index.ts | 33 +++++ apps/ui/src/data/core/index.ts | 2 + apps/ui/src/data/core/types.ts | 40 ++++++ .../src/data/onboarding/orientation-guide.ts | 91 +++++++++++++ .../use-orientation-autostart.test.ts | 76 +++++++++++ .../onboarding/use-orientation-autostart.ts | 123 ++++++++++++++++++ .../data/onboarding/use-orientation-replay.ts | 41 ++++++ .../src/data/queries/use-onboarding-hints.ts | 65 +++++++++ .../router/layout-dashboard/index.tsx | 6 + 23 files changed, 856 insertions(+), 1 deletion(-) create mode 100644 apps/ui/src/components/onboarding-guide/illustrations.tsx create mode 100644 apps/ui/src/components/onboarding-guide/index.tsx create mode 100644 apps/ui/src/components/onboarding-guide/style.module.css create mode 100644 apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx create mode 100644 apps/ui/src/data/onboarding/orientation-guide.ts create mode 100644 apps/ui/src/data/onboarding/use-orientation-autostart.test.ts create mode 100644 apps/ui/src/data/onboarding/use-orientation-autostart.ts create mode 100644 apps/ui/src/data/onboarding/use-orientation-replay.ts create mode 100644 apps/ui/src/data/queries/use-onboarding-hints.ts diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index cbdf6bb2bd..21fe1bdd88 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -232,6 +232,7 @@ export { getColorScheme, getGlobalAgentInstructions, getInstalledAppsAndTerminals, + getOnboardingHints, getQuitSitesBehavior, getUserEditor, getUserLocale, @@ -240,6 +241,7 @@ export { previewColorScheme, saveColorScheme, saveGlobalAgentInstructions, + saveOnboardingHints, saveQuitSitesBehavior, saveUserEditor, saveUserLocale, diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 88efa4bc39..be1c399616 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -44,6 +44,7 @@ export interface IpcEvents { 'snapshot-key-value': [ { operationId: crypto.UUID; data: SnapshotKeyValueEventData } ]; 'snapshot-success': [ { operationId: crypto.UUID } ]; 'show-whats-new': [ void ]; + 'show-getting-started': [ void ]; 'sync-connect-site': [ { remoteSiteId: number; diff --git a/apps/studio/src/menu.ts b/apps/studio/src/menu.ts index 3f7fe061d0..b611ba57f3 100644 --- a/apps/studio/src/menu.ts +++ b/apps/studio/src/menu.ts @@ -429,6 +429,13 @@ async function getAppMenu( }, enabled: ! needsOnboarding, }, + { + label: __( 'Getting Started' ), + click: async () => { + void sendIpcEventToRenderer( 'show-getting-started' ); + }, + enabled: ! needsOnboarding, + }, { type: 'separator' }, ...( process.platform === 'win32' ? [ diff --git a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts index 92f133e432..30b6e8627d 100644 --- a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts @@ -12,6 +12,7 @@ import { SUPPORTED_EDITORS, SupportedEditor } from 'src/modules/user-settings/li import { SupportedTerminal } from 'src/modules/user-settings/lib/terminal'; import { UserSettingsTabName } from 'src/modules/user-settings/user-settings-types'; import { defaultSitePath, ensureWritableDirectory } from 'src/storage/paths'; +import { OnboardingHintsState } from 'src/storage/storage-types'; import { loadUserData, lockAppdata, @@ -146,6 +147,40 @@ export async function getWapuuScore(): Promise< number | undefined > { return userData.wapuuScore; } +// Agentic UI onboarding state (orientation tour, getting-started checklist). +// The blob is opaque to the desktop; the renderer owns its meaning. +export async function getOnboardingHints(): Promise< OnboardingHintsState > { + const userData = await loadUserData(); + return userData.onboardingHints ?? {}; +} + +export async function saveOnboardingHints( + _event: IpcMainInvokeEvent, + partial: Partial< OnboardingHintsState > +): Promise< void > { + if ( ! partial || typeof partial !== 'object' ) { + return; + } + await lockAppdata(); + try { + const userData = await loadUserData(); + const current = userData.onboardingHints ?? {}; + // Shallow-merge, but merge completedItems by key so concurrent item + // completions never clobber one another. + const merged: OnboardingHintsState = { + ...current, + ...partial, + completedItems: { + ...( current.completedItems ?? {} ), + ...( partial.completedItems ?? {} ), + }, + }; + await saveUserData( { ...userData, onboardingHints: merged } ); + } finally { + await unlockAppdata(); + } +} + export async function getGlobalAgentInstructions(): Promise< string > { return ( await readGlobalInstructionsFile() ) ?? ''; } diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 86c8e9e441..104306fed8 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -172,6 +172,8 @@ const api: IpcApi = { getQuitSitesBehavior: () => ipcRendererInvoke( 'getQuitSitesBehavior' ), saveWapuuScore: ( score ) => ipcRendererInvoke( 'saveWapuuScore', score ), getWapuuScore: () => ipcRendererInvoke( 'getWapuuScore' ), + getOnboardingHints: () => ipcRendererInvoke( 'getOnboardingHints' ), + saveOnboardingHints: ( partial ) => ipcRendererInvoke( 'saveOnboardingHints', partial ), getUserEditor: () => ipcRendererInvoke( 'getUserEditor' ), saveUserEditor: ( editor ) => ipcRendererInvoke( 'saveUserEditor', editor ), comparePaths: ( path1, path2 ) => ipcRendererInvoke( 'comparePaths', path1, path2 ), diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index 147d51e936..29d5a1dd4b 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -54,6 +54,8 @@ export interface UserData { lastNightlyUpdateCheck?: number; nightlyPromptResult?: NightlyPromptResult; agenticUiBannerDismissed?: boolean; + /** Agentic UI onboarding state (orientation tour, getting-started checklist). Opaque blob owned by the renderer. */ + onboardingHints?: OnboardingHintsState; } export interface PromptWindowsSpeedUpResult { @@ -62,6 +64,18 @@ export interface PromptWindowsSpeedUpResult { dontAskAgain: boolean; } +// Mirror of the renderer's OnboardingHintsState (apps/ui/src/data/core/types.ts). +// Persisted verbatim; the desktop never inspects it, so a structural shape keeps +// the two sides decoupled. +export interface OnboardingHintsState { + tourCompletedVersion?: number; + tourDismissedVersion?: number; + checklistDismissed?: boolean; + checklistMinimized?: boolean; + completedItems?: Record< string, string >; + publishCoachmarkShown?: boolean; +} + export const EMPTY_USER_DATA: UserData = { version: 1, siteMetadata: {}, diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index 95ed9675b8..d28d7efcea 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -83,6 +83,7 @@ type UserDataSafeKeys = | 'cliAutoInstalled' | 'cliUserUninstalled' | 'wapuuScore' + | 'onboardingHints' | 'lastNightlyUpdateCheck' | 'nightlyPromptResult' | 'agenticUiBannerDismissed'; diff --git a/apps/ui/src/app/app-providers.tsx b/apps/ui/src/app/app-providers.tsx index 158ffb3555..a2157a3908 100644 --- a/apps/ui/src/app/app-providers.tsx +++ b/apps/ui/src/app/app-providers.tsx @@ -4,6 +4,7 @@ import { I18nProvider } from '@wordpress/react-i18n'; import { privateApis } from '@wordpress/theme'; import { Tooltip } from '@wordpress/ui'; import { useEffect } from 'react'; +import { OnboardingGuideProvider } from '@/components/onboarding-guide/use-onboarding-guide'; import { ConnectorProvider, queryClient } from '@/data/core'; import { AgentRunProvider } from '@/data/queries/use-agent-run'; import { useSyncAppUpdateStatus } from '@/data/queries/use-app-update'; @@ -42,7 +43,9 @@ function ThemedApp( { children }: PropsWithChildren ) { }, [ colorScheme ] ); return ( - { children } + + { children } + ); } diff --git a/apps/ui/src/components/onboarding-guide/illustrations.tsx b/apps/ui/src/components/onboarding-guide/illustrations.tsx new file mode 100644 index 0000000000..4d20f7faa6 --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/illustrations.tsx @@ -0,0 +1,9 @@ +import styles from './style.module.css'; +import type { OrientationIllustrationId } from '@/data/onboarding/orientation-guide'; + +// Placeholder for the guide's header art. Real illustrations (keyed by the +// page's illustration id) drop in here; until then this is just the tinted +// slot at the correct size. +export function OrientationIllustration( { id }: { id: OrientationIllustrationId } ) { + return
; +} diff --git a/apps/ui/src/components/onboarding-guide/index.tsx b/apps/ui/src/components/onboarding-guide/index.tsx new file mode 100644 index 0000000000..309032131d --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/index.tsx @@ -0,0 +1,84 @@ +import { __ } from '@wordpress/i18n'; +import { Button, Dialog } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { useState } from 'react'; +import { OrientationIllustration } from './illustrations'; +import styles from './style.module.css'; +import type { GuideDefinition } from '@/data/onboarding/orientation-guide'; + +interface OnboardingGuideProps { + guide: GuideDefinition; + onComplete: () => void; + onDismiss: () => void; +} + +// A focused, paged orientation modal in the spirit of Gutenberg's Guide: +// full-bleed illustration on top, copy in the middle, a dot pager and +// Back/Next at the bottom. It demands attention — a backdrop, no click-outside +// dismissal — so the welcome isn't lost with a stray click. +export function OnboardingGuide( { guide, onComplete, onDismiss }: OnboardingGuideProps ) { + const [ pageIndex, setPageIndex ] = useState( 0 ); + const page = guide.pages[ pageIndex ]; + const isFirst = pageIndex === 0; + const isLast = pageIndex === guide.pages.length - 1; + + const goNext = () => { + if ( isLast ) { + onComplete(); + } else { + setPageIndex( ( index ) => index + 1 ); + } + }; + const goBack = () => setPageIndex( ( index ) => Math.max( 0, index - 1 ) ); + + return ( + { + if ( open ) { + return; + } + if ( details.reason === 'escape-key' || details.reason === 'close-press' ) { + onDismiss(); + } + } } + > + + + + + { page.title() } + + { page.description() } + + + +
+ { ! isFirst ? ( + + ) : null } +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/onboarding-guide/style.module.css b/apps/ui/src/components/onboarding-guide/style.module.css new file mode 100644 index 0000000000..3e640bbade --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/style.module.css @@ -0,0 +1,108 @@ +/* Width only. The wpds dialog popup owns its own positioning + (position: fixed + translate centering) and clipping inside + @layer wp-ui-components — this module's classes are unlayered and would + silently clobber any property repeated here, which is exactly how an + earlier `position: relative` dropped the dialog into document flow and + broke both its centering and the app layout behind it. Add properties + only after checking them against the base popup styles. */ +.popup { + width: 380px; + max-width: calc(100vw - 32px); +} + +/* Full-bleed illustration slot (placeholder until real art lands). A fixed + aspect ratio gives the header a stable height that doesn't depend on + content, so it can't collapse while the dialog animates in. */ +.illustration { + display: block; + width: 100%; + aspect-ratio: 320 / 150; + /* The popup is a flex column; never let the header art get squeezed when + the viewport height constrains the popup. */ + flex-shrink: 0; + background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 8%, transparent); + border-bottom: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak); +} + +.close { + position: absolute; + top: var(--wpds-dimension-padding-sm); + right: var(--wpds-dimension-padding-sm); + z-index: 1; +} + +.content { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-sm); + /* Sized to the tallest page (a title over a three-line description) so the + modal height stays constant as the copy changes between pages. */ + min-height: 132px; + box-sizing: border-box; + padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-xl) + var(--wpds-dimension-padding-lg); + text-align: left; +} + +.title { + margin: 0; + font-size: var(--wpds-typography-font-size-lg); + font-weight: var(--wpds-typography-font-weight-medium); + line-height: 1.3; + text-wrap: balance; +} + +.description { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-md); + line-height: 1.5; + text-wrap: pretty; +} + +/* Three zones: Back on the left, the pager centered, the primary action on the + right. The 1fr side columns keep the pager centered even when Back is absent + (first page). */ +.footer { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--wpds-dimension-gap-md); + padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-xl) + var(--wpds-dimension-padding-xl); +} + +.footerStart { + justify-self: start; +} + +.footerEnd { + justify-self: end; +} + +.pager { + justify-self: center; + display: flex; + align-items: center; + gap: 6px; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 22%, transparent); + transition: background 160ms ease, width 160ms ease; +} + +.dotActive { + width: 18px; + border-radius: 3px; + background: var(--wpds-color-fg-interactive-brand); +} + +@media (prefers-reduced-motion: reduce) { + .dot { + transition: none; + } +} diff --git a/apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx b/apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx new file mode 100644 index 0000000000..0fad352abe --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx @@ -0,0 +1,64 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { getOrientationGuide } from '@/data/onboarding/orientation-guide'; +import { OnboardingGuide } from './index'; +import type { OrientationVariant } from '@/data/onboarding/orientation-guide'; +import type { ReactNode } from 'react'; + +export type GuideEndReason = 'completed' | 'dismissed'; + +interface OpenGuideOptions { + onEnd?: ( reason: GuideEndReason ) => void; +} + +interface OnboardingGuideApi { + isOpen: boolean; + openGuide( variant: OrientationVariant, options?: OpenGuideOptions ): void; + close( reason: GuideEndReason ): void; +} + +export type OpenGuide = OnboardingGuideApi[ 'openGuide' ]; + +const OnboardingGuideContext = createContext< OnboardingGuideApi | null >( null ); + +export function OnboardingGuideProvider( { children }: { children: ReactNode } ) { + const [ variant, setVariant ] = useState< OrientationVariant | null >( null ); + const onEndRef = useRef< ( ( reason: GuideEndReason ) => void ) | null >( null ); + + const openGuide = useCallback( ( next: OrientationVariant, options?: OpenGuideOptions ) => { + onEndRef.current = options?.onEnd ?? null; + setVariant( next ); + }, [] ); + + const close = useCallback( ( reason: GuideEndReason ) => { + const callback = onEndRef.current; + onEndRef.current = null; + setVariant( null ); + callback?.( reason ); + }, [] ); + + const api = useMemo< OnboardingGuideApi >( + () => ( { isOpen: variant !== null, openGuide, close } ), + [ variant, openGuide, close ] + ); + + return ( + + { children } + { variant ? ( + close( 'completed' ) } + onDismiss={ () => close( 'dismissed' ) } + /> + ) : null } + + ); +} + +export function useOnboardingGuide(): OnboardingGuideApi { + const api = useContext( OnboardingGuideContext ); + if ( ! api ) { + throw new Error( 'useOnboardingGuide must be used within an OnboardingGuideProvider' ); + } + return api; +} diff --git a/apps/ui/src/data/core/connectors/hosted/index.ts b/apps/ui/src/data/core/connectors/hosted/index.ts index 6041de6c32..dd5b90b770 100644 --- a/apps/ui/src/data/core/connectors/hosted/index.ts +++ b/apps/ui/src/data/core/connectors/hosted/index.ts @@ -11,6 +11,7 @@ import type { Connector, InstalledApps, LoadedAiSession, + OnboardingHintsState, SiteDetails, Snapshot, SnapshotUsage, @@ -24,6 +25,29 @@ export interface HostedConnectorOptions { apiBaseUrl: string; } +// Workbench onboarding state persists per origin in the browser surface. +const ONBOARDING_HINTS_STORAGE_KEY = 'studio-onboarding-hints'; + +function readOnboardingHints(): OnboardingHintsState { + try { + const raw = window.localStorage.getItem( ONBOARDING_HINTS_STORAGE_KEY ); + const parsed: unknown = raw ? JSON.parse( raw ) : {}; + return parsed && typeof parsed === 'object' ? ( parsed as OnboardingHintsState ) : {}; + } catch { + return {}; + } +} + +function writeOnboardingHints( partial: Partial< OnboardingHintsState > ): void { + const current = readOnboardingHints(); + const merged: OnboardingHintsState = { + ...current, + ...partial, + completedItems: { ...( current.completedItems ?? {} ), ...( partial.completedItems ?? {} ) }, + }; + window.localStorage.setItem( ONBOARDING_HINTS_STORAGE_KEY, JSON.stringify( merged ) ); +} + // Envelope used by the backend's `/events` SSE stream so a single connection // can carry both agent-run events and session-placement updates. type ServerEvent = @@ -430,6 +454,16 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ): async disableAgenticUi() { // No-op in the browser. }, + async getOnboardingHints() { + return readOnboardingHints(); + }, + async setOnboardingHints( partial ) { + writeOnboardingHints( partial ); + }, + onShowGettingStarted() { + // No application menu on the hosted surface. + return () => {}; + }, async getAppUpdateStatus() { return { readyToInstall: false, version: null }; }, diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index e29d5f1988..487999d0c5 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -851,6 +851,20 @@ export function createIpcConnector(): Connector { await ipcApi.disableAgenticUi(); }, + async getOnboardingHints() { + return ipcApi.getOnboardingHints(); + }, + + async setOnboardingHints( partial ) { + await ipcApi.saveOnboardingHints( partial ); + }, + + onShowGettingStarted( listener ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcListener = ( window as any ).ipcListener; + return ipcListener.subscribe( 'show-getting-started', () => listener() ); + }, + async getAppUpdateStatus() { return ipcApi.getAppUpdateStatus(); }, diff --git a/apps/ui/src/data/core/connectors/local/index.ts b/apps/ui/src/data/core/connectors/local/index.ts index 8932e1a374..23cd28b058 100644 --- a/apps/ui/src/data/core/connectors/local/index.ts +++ b/apps/ui/src/data/core/connectors/local/index.ts @@ -16,6 +16,7 @@ import type { InstalledApps, LoadedAiSession, LocalMediaFile, + OnboardingHintsState, ProposedSitePath, QuitSitesBehavior, SelectedSiteFolder, @@ -39,6 +40,28 @@ const COLOR_SCHEME_STORAGE_KEY = 'studio-local-color-scheme'; const EDITOR_STORAGE_KEY = 'studio-local-editor'; const TERMINAL_STORAGE_KEY = 'studio-local-terminal'; const QUIT_SITES_BEHAVIOR_STORAGE_KEY = 'studio-local-quit-sites-behavior'; +// Workbench onboarding state persists per origin in the browser surface. +const ONBOARDING_HINTS_STORAGE_KEY = 'studio-onboarding-hints'; + +function readOnboardingHints(): OnboardingHintsState { + try { + const raw = window.localStorage.getItem( ONBOARDING_HINTS_STORAGE_KEY ); + const parsed: unknown = raw ? JSON.parse( raw ) : {}; + return parsed && typeof parsed === 'object' ? ( parsed as OnboardingHintsState ) : {}; + } catch { + return {}; + } +} + +function writeOnboardingHints( partial: Partial< OnboardingHintsState > ): void { + const current = readOnboardingHints(); + const merged: OnboardingHintsState = { + ...current, + ...partial, + completedItems: { ...( current.completedItems ?? {} ), ...( partial.completedItems ?? {} ) }, + }; + window.localStorage.setItem( ONBOARDING_HINTS_STORAGE_KEY, JSON.stringify( merged ) ); +} function parseQuitSitesBehavior( value: string | null ): QuitSitesBehavior | undefined { return value === 'leave-running' || value === 'stop-and-auto-start' || value === 'stop' @@ -782,6 +805,16 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C async disableAgenticUi() { // No-op in the browser. }, + async getOnboardingHints() { + return readOnboardingHints(); + }, + async setOnboardingHints( partial ) { + writeOnboardingHints( partial ); + }, + onShowGettingStarted() { + // No application menu in a browser tab. + return () => {}; + }, async getAppUpdateStatus() { return { readyToInstall: false, version: null }; }, diff --git a/apps/ui/src/data/core/index.ts b/apps/ui/src/data/core/index.ts index 878f648856..02c32b660b 100644 --- a/apps/ui/src/data/core/index.ts +++ b/apps/ui/src/data/core/index.ts @@ -6,6 +6,7 @@ export type { AppGlobals, AppUpdateStatus, AuthUser, + ChecklistItemId, ColorScheme, Connector, CreateSiteParams, @@ -13,6 +14,7 @@ export type { InstalledApps, LocalMediaFile, LoadedAiSession, + OnboardingHintsState, ProposedSitePath, QuitSitesBehavior, SelectedSiteFolder, diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 5d05876e16..98b4749fec 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -424,6 +424,17 @@ export interface Connector { // Switches back to the legacy (classic) Studio UI. disableAgenticUi(): Promise< void >; + // Agentic UI onboarding state (orientation tour + getting-started + // checklist). Distinct from getOnboardingCompleted (the pre-workbench + // first-run welcome flag). setOnboardingHints shallow-merges its partial; + // completedItems is merged by key. Hosted/web persist to localStorage. + getOnboardingHints(): Promise< OnboardingHintsState >; + setOnboardingHints( partial: Partial< OnboardingHintsState > ): Promise< void >; + + // Fires when the user picks Help ▸ Getting Started in the application menu + // (desktop only). No-ops where there's no OS menu. + onShowGettingStarted( listener: () => void ): () => void; + // Auto-updater status. getAppUpdateStatus(): Promise< AppUpdateStatus >; installAppUpdate(): Promise< void >; @@ -435,6 +446,35 @@ export interface AppUpdateStatus { version: string | null; } +// Getting-started checklist item ids. Kept as a closed union so the checklist +// definitions, completion watchers, and persistence all agree on the set. +export type ChecklistItemId = + | 'create-site' + | 'first-agent-edit' + | 'visit-overview' + | 'publish-site' + | 'visit-app-settings' + | 'visit-site-settings'; + +// Persisted first-run onboarding state for the workbench. Separate from the +// pre-workbench welcome flag (getOnboardingCompleted) and from dismissed +// messages (which are append-only and so can't model replay/un-dismiss). +export interface OnboardingHintsState { + // Version of the orientation tour the user finished or explicitly skipped. + tourCompletedVersion?: number; + // Version of the orientation tour the user closed early (Esc / X). + tourDismissedVersion?: number; + // True once the getting-started checklist has been dismissed. Replay clears + // this — hence it can't ride the append-only dismissedMessages store. + checklistDismissed?: boolean; + // True while the checklist is collapsed to its compact (toast-like) bar. + checklistMinimized?: boolean; + // Completed checklist items → ISO timestamp of completion. + completedItems?: Partial< Record< ChecklistItemId, string > >; + // True once the one-shot publish coachmark has been shown (never re-fires). + publishCoachmarkShown?: boolean; +} + export interface SnapshotUsage { siteCount: number; siteLimit: number; diff --git a/apps/ui/src/data/onboarding/orientation-guide.ts b/apps/ui/src/data/onboarding/orientation-guide.ts new file mode 100644 index 0000000000..50d425eab4 --- /dev/null +++ b/apps/ui/src/data/onboarding/orientation-guide.ts @@ -0,0 +1,91 @@ +import { __ } from '@wordpress/i18n'; + +// Bump to re-show the orientation guide to everyone who saw the previous +// version (compared against OnboardingHintsState.tourCompletedVersion / +// tourDismissedVersion — the field names predate the modal and stay generic). +export const ORIENTATION_GUIDE_VERSION = 1; + +export type OrientationVariant = 'agentic' | 'overview'; + +export type OrientationIllustrationId = 'sites' | 'chat' | 'preview' | 'overview'; + +export interface GuidePage { + illustration: OrientationIllustrationId; + title: () => string; + description: () => string; + // The advance button's label — the user's reaction to this page ("Cool", + // "Wait, really?"), not a generic "Next". + action: () => string; +} + +export interface GuideDefinition { + id: OrientationVariant; + version: number; + pages: GuidePage[]; +} + +const SITES_PAGE: GuidePage = { + illustration: 'sites', + title: () => __( 'Welcome to your workbench' ), + description: () => + __( + 'Every site you build lives in the sidebar on the left. Switch between them anytime — each keeps its own history.' + ), + action: () => __( 'Cool' ), +}; + +// Final page in both variants, so its action closes the guide. +const PREVIEW_PAGE: GuidePage = { + illustration: 'preview', + title: () => __( 'See it live' ), + description: () => + __( + 'Your site previews on the right and updates as changes land. Switch between the front end, WP Admin, and the database from the toolbar.' + ), + action: () => __( 'Let’s go' ), +}; + +// Agentic variant: the user lands in a chat session, so the middle beat is the +// agent. Ends on the preview so the connection between asking and seeing is +// the last thing they read before their first prompt. +export const AGENTIC_ORIENTATION_GUIDE: GuideDefinition = { + id: 'agentic', + version: ORIENTATION_GUIDE_VERSION, + pages: [ + SITES_PAGE, + { + illustration: 'chat', + title: () => __( 'Build by asking' ), + description: () => + __( + 'Describe what you want in plain language — add a page, change the design, fix a bug — and the agent builds it. Start from a suggestion or just type.' + ), + action: () => __( 'Wait, really?' ), + }, + PREVIEW_PAGE, + ], +}; + +// Non-agentic variant: no chat. The middle beat is the site overview and its +// customize/manage tools. +export const OVERVIEW_ORIENTATION_GUIDE: GuideDefinition = { + id: 'overview', + version: ORIENTATION_GUIDE_VERSION, + pages: [ + SITES_PAGE, + { + illustration: 'overview', + title: () => __( 'Manage your site' ), + description: () => + __( + 'The site overview is your control panel — open the editor and admin tools, or duplicate, export, and manage your site from one place.' + ), + action: () => __( 'That’s handy' ), + }, + PREVIEW_PAGE, + ], +}; + +export function getOrientationGuide( variant: OrientationVariant ): GuideDefinition { + return variant === 'agentic' ? AGENTIC_ORIENTATION_GUIDE : OVERVIEW_ORIENTATION_GUIDE; +} diff --git a/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts b/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts new file mode 100644 index 0000000000..5eec32fb12 --- /dev/null +++ b/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { ORIENTATION_GUIDE_VERSION } from './orientation-guide'; +import { deriveOrientationAutostart } from './use-orientation-autostart'; +import type { OnboardingHintsState } from '@/data/core'; + +const base = { + onboardingCompleted: true, + siteCount: 1, + agentic: { enabled: true, isReady: true }, + hints: {} as OnboardingHintsState, + guideOpen: false, + alreadyStarted: false, +}; + +describe( 'deriveOrientationAutostart', () => { + it( 'opens the agentic guide when everything is ready', () => { + expect( deriveOrientationAutostart( base ) ).toBe( 'agentic' ); + } ); + + it( 'opens the overview guide when agentic features are disabled', () => { + expect( + deriveOrientationAutostart( { ...base, agentic: { enabled: false, isReady: true } } ) + ).toBe( 'overview' ); + } ); + + it( 'waits until the pre-workbench welcome is done', () => { + expect( deriveOrientationAutostart( { ...base, onboardingCompleted: false } ) ).toBeNull(); + expect( deriveOrientationAutostart( { ...base, onboardingCompleted: undefined } ) ).toBeNull(); + } ); + + it( 'waits until there is at least one site', () => { + expect( deriveOrientationAutostart( { ...base, siteCount: 0 } ) ).toBeNull(); + } ); + + it( 'waits until the agentic gate has resolved', () => { + expect( + deriveOrientationAutostart( { ...base, agentic: { enabled: true, isReady: false } } ) + ).toBeNull(); + } ); + + it( 'waits until hints have loaded', () => { + expect( deriveOrientationAutostart( { ...base, hints: undefined } ) ).toBeNull(); + } ); + + it( 'does not re-open a completed guide of the current version', () => { + expect( + deriveOrientationAutostart( { + ...base, + hints: { tourCompletedVersion: ORIENTATION_GUIDE_VERSION }, + } ) + ).toBeNull(); + } ); + + it( 'does not re-open a dismissed guide of the current version', () => { + expect( + deriveOrientationAutostart( { + ...base, + hints: { tourDismissedVersion: ORIENTATION_GUIDE_VERSION }, + } ) + ).toBeNull(); + } ); + + it( 're-arms when the guide version is bumped past the seen version', () => { + expect( + deriveOrientationAutostart( { + ...base, + hints: { tourCompletedVersion: ORIENTATION_GUIDE_VERSION - 1 }, + } ) + ).toBe( 'agentic' ); + } ); + + it( 'does not open while the guide is already open or already started', () => { + expect( deriveOrientationAutostart( { ...base, guideOpen: true } ) ).toBeNull(); + expect( deriveOrientationAutostart( { ...base, alreadyStarted: true } ) ).toBeNull(); + } ); +} ); diff --git a/apps/ui/src/data/onboarding/use-orientation-autostart.ts b/apps/ui/src/data/onboarding/use-orientation-autostart.ts new file mode 100644 index 0000000000..2a8696a6a3 --- /dev/null +++ b/apps/ui/src/data/onboarding/use-orientation-autostart.ts @@ -0,0 +1,123 @@ +import { useEffect, useRef } from 'react'; +import { useOnboardingGuide } from '@/components/onboarding-guide/use-onboarding-guide'; +import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; +import { + useOnboardingCompleted, + useOnboardingHints, + useSetOnboardingHints, +} from '@/data/queries/use-onboarding-hints'; +import { useSites } from '@/data/queries/use-sites'; +import { ORIENTATION_GUIDE_VERSION } from './orientation-guide'; +import type { OrientationVariant } from './orientation-guide'; +import type { OnboardingHintsState } from '@/data/core'; + +interface AutostartInputs { + onboardingCompleted: boolean | undefined; + siteCount: number; + agentic: { enabled: boolean; isReady: boolean }; + hints: OnboardingHintsState | undefined; + guideOpen: boolean; + alreadyStarted: boolean; +} + +/** + * Pure decision: which orientation guide variant (if any) to auto-open. + * Returns null unless the user finished the pre-workbench welcome, has at least + * one site, the agentic gate has resolved, hints have loaded, nothing is + * already showing, and this app session hasn't opened the guide yet. + */ +export function deriveOrientationAutostart( { + onboardingCompleted, + siteCount, + agentic, + hints, + guideOpen, + alreadyStarted, +}: AutostartInputs ): OrientationVariant | null { + if ( alreadyStarted || guideOpen ) { + return null; + } + if ( onboardingCompleted !== true || siteCount < 1 || ! agentic.isReady ) { + return null; + } + if ( hints === undefined ) { + return null; + } + const seen = + ( hints.tourCompletedVersion ?? 0 ) >= ORIENTATION_GUIDE_VERSION || + ( hints.tourDismissedVersion ?? 0 ) >= ORIENTATION_GUIDE_VERSION; + if ( seen ) { + return null; + } + return agentic.enabled ? 'agentic' : 'overview'; +} + +// Let the workbench render and settle before the guide appears, so it reads as +// an entrance rather than part of the initial paint. +const GUIDE_START_DELAY_MS = 500; + +/** + * Auto-opens the orientation guide once on first arrival in the workbench. + * Mounted in the dashboard layout. + */ +export function useOrientationAutostart(): void { + const { data: sites } = useSites(); + const agentic = useAgenticFeatures(); + const { data: hints } = useOnboardingHints(); + const setHints = useSetOnboardingHints(); + const { isOpen, openGuide } = useOnboardingGuide(); + + const { data: onboardingCompleted } = useOnboardingCompleted(); + + const startedRef = useRef( false ); + const startTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + + useEffect( () => { + const variant = deriveOrientationAutostart( { + onboardingCompleted, + siteCount: sites?.length ?? 0, + agentic: { enabled: agentic.enabled, isReady: agentic.isReady }, + hints, + guideOpen: isOpen, + alreadyStarted: startedRef.current, + } ); + if ( ! variant ) { + return; + } + // Mark started immediately so a dependency change can't schedule twice. + startedRef.current = true; + startTimerRef.current = setTimeout( () => { + startTimerRef.current = null; + openGuide( variant, { + onEnd: ( reason ) => { + if ( reason === 'completed' ) { + setHints.mutate( { tourCompletedVersion: ORIENTATION_GUIDE_VERSION } ); + } else { + setHints.mutate( { tourDismissedVersion: ORIENTATION_GUIDE_VERSION } ); + } + }, + } ); + }, GUIDE_START_DELAY_MS ); + // No timer cleanup here: a dependency change re-runs this effect and + // returns early (startedRef guard); clearing on every re-run would + // cancel the pending open. The mount-scoped cleanup below handles it. + }, [ + onboardingCompleted, + sites?.length, + agentic.enabled, + agentic.isReady, + hints, + isOpen, + openGuide, + setHints, + ] ); + + useEffect( () => { + return () => { + if ( startTimerRef.current ) { + clearTimeout( startTimerRef.current ); + startTimerRef.current = null; + } + }; + }, [] ); +} diff --git a/apps/ui/src/data/onboarding/use-orientation-replay.ts b/apps/ui/src/data/onboarding/use-orientation-replay.ts new file mode 100644 index 0000000000..aa9fe5523e --- /dev/null +++ b/apps/ui/src/data/onboarding/use-orientation-replay.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from 'react'; +import { useOnboardingGuide } from '@/components/onboarding-guide/use-onboarding-guide'; +import { useConnector } from '@/data/core'; +import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; +import { useSetOnboardingHints } from '@/data/queries/use-onboarding-hints'; +import { ORIENTATION_GUIDE_VERSION } from './orientation-guide'; + +/** + * Reopens the orientation guide when the user picks Help ▸ Getting Started in + * the application menu. Finishing or skipping the replay records the seen + * version just like the first-run autostart, so the guide never re-appears on + * its own afterward. No-ops on surfaces without an OS menu — the connector + * subscription simply never fires. + */ +export function useOrientationReplay(): void { + const connector = useConnector(); + const agentic = useAgenticFeatures(); + const setHints = useSetOnboardingHints(); + const { openGuide } = useOnboardingGuide(); + + // The menu event fires outside React's data flow, so read the latest gate + // through a ref instead of resubscribing every time it changes. + const enabledRef = useRef( agentic.enabled ); + useEffect( () => { + enabledRef.current = agentic.enabled; + }, [ agentic.enabled ] ); + + useEffect( () => { + return connector.onShowGettingStarted( () => { + openGuide( enabledRef.current ? 'agentic' : 'overview', { + onEnd: ( reason ) => { + setHints.mutate( + reason === 'completed' + ? { tourCompletedVersion: ORIENTATION_GUIDE_VERSION } + : { tourDismissedVersion: ORIENTATION_GUIDE_VERSION } + ); + }, + } ); + } ); + }, [ connector, openGuide, setHints ] ); +} diff --git a/apps/ui/src/data/queries/use-onboarding-hints.ts b/apps/ui/src/data/queries/use-onboarding-hints.ts new file mode 100644 index 0000000000..c21f600e4c --- /dev/null +++ b/apps/ui/src/data/queries/use-onboarding-hints.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useConnector } from '@/data/core'; +import type { OnboardingHintsState } from '@/data/core'; + +// Persisted first-run onboarding state (orientation tour + getting-started +// checklist). Backed by the connector: desktop → app.json, hosted/web → +// localStorage. Read once and kept forever — writes are rare and go through the +// optimistic mutation below. + +export const ONBOARDING_HINTS_QUERY_KEY = [ 'onboarding-hints' ] as const; +export const ONBOARDING_COMPLETED_QUERY_KEY = [ 'onboarding-completed' ] as const; + +export function useOnboardingHints() { + const connector = useConnector(); + return useQuery( { + queryKey: ONBOARDING_HINTS_QUERY_KEY, + queryFn: () => connector.getOnboardingHints(), + staleTime: Infinity, + meta: { persist: false }, + } ); +} + +// Whether the user has finished (or skipped) the pre-workbench welcome. Gates +// tour auto-start so it never appears mid-NUX. +export function useOnboardingCompleted() { + const connector = useConnector(); + return useQuery( { + queryKey: ONBOARDING_COMPLETED_QUERY_KEY, + queryFn: () => connector.getOnboardingCompleted(), + staleTime: Infinity, + } ); +} + +// Shallow-merge a partial into the cached hints, merging completedItems by key +// so a checklist completion never clobbers a concurrent one. +function mergeHints( + current: OnboardingHintsState | undefined, + partial: Partial< OnboardingHintsState > +): OnboardingHintsState { + return { + ...( current ?? {} ), + ...partial, + completedItems: { + ...( current?.completedItems ?? {} ), + ...( partial.completedItems ?? {} ), + }, + }; +} + +export function useSetOnboardingHints() { + const connector = useConnector(); + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: ( partial: Partial< OnboardingHintsState > ) => + connector.setOnboardingHints( partial ), + // Optimistic so the UI reacts immediately; the connector write is the + // source of truth on next launch. + onMutate: ( partial ) => { + queryClient.setQueryData( + ONBOARDING_HINTS_QUERY_KEY, + ( current: OnboardingHintsState | undefined ) => mergeHints( current, partial ) + ); + }, + } ); +} diff --git a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx index 86b71c5d7e..57827c6c02 100644 --- a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx +++ b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx @@ -7,6 +7,8 @@ import { } from '@/components/preview-split-frame'; import { SidebarLayout } from '@/components/sidebar-layout'; import { SitePreview } from '@/components/site-preview'; +import { useOrientationAutostart } from '@/data/onboarding/use-orientation-autostart'; +import { useOrientationReplay } from '@/data/onboarding/use-orientation-replay'; import { useSession, useSessionEffectiveEnvironment } from '@/data/queries/use-sessions'; import { useSites } from '@/data/queries/use-sites'; import { @@ -58,6 +60,10 @@ function DashboardLayoutContent() { const { sessionId, overviewSiteId, newSessionSiteId } = routePreviewContext; const { data: sites } = useSites(); const { data: sessionData } = useSession( sessionId ); + // Open the orientation guide on first workbench arrival, and let Help ▸ + // Getting Started replay it. + useOrientationAutostart(); + useOrientationReplay(); const preview = useSessionPreviewUI(); const onAnnotationsDone = useSessionPreviewAnnotationsHandler(); const sessionSite = findAiSessionOwnerSite( sites, sessionData?.summary ); From eb0e68088ebdb1e1b4876e7989ddfcafae4d9607 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 24 Jul 2026 14:14:49 +0100 Subject: [PATCH 2/7] Scope onboarding hints to the guide and share the browser store --- .../modules/user-settings/lib/ipc-handlers.ts | 12 +------ apps/studio/src/storage/storage-types.ts | 4 --- .../connectors/browser-onboarding-hints.ts | 21 ++++++++++++ .../src/data/core/connectors/hosted/index.ts | 25 +------------- .../src/data/core/connectors/local/index.ts | 24 +------------- apps/ui/src/data/core/index.ts | 1 - apps/ui/src/data/core/types.ts | 33 ++++--------------- .../src/data/queries/use-onboarding-hints.ts | 18 +--------- 8 files changed, 31 insertions(+), 107 deletions(-) create mode 100644 apps/ui/src/data/core/connectors/browser-onboarding-hints.ts diff --git a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts index 30b6e8627d..a4e581a5cf 100644 --- a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts @@ -164,17 +164,7 @@ export async function saveOnboardingHints( await lockAppdata(); try { const userData = await loadUserData(); - const current = userData.onboardingHints ?? {}; - // Shallow-merge, but merge completedItems by key so concurrent item - // completions never clobber one another. - const merged: OnboardingHintsState = { - ...current, - ...partial, - completedItems: { - ...( current.completedItems ?? {} ), - ...( partial.completedItems ?? {} ), - }, - }; + const merged: OnboardingHintsState = { ...( userData.onboardingHints ?? {} ), ...partial }; await saveUserData( { ...userData, onboardingHints: merged } ); } finally { await unlockAppdata(); diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index 29d5a1dd4b..51750a7dd4 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -70,10 +70,6 @@ export interface PromptWindowsSpeedUpResult { export interface OnboardingHintsState { tourCompletedVersion?: number; tourDismissedVersion?: number; - checklistDismissed?: boolean; - checklistMinimized?: boolean; - completedItems?: Record< string, string >; - publishCoachmarkShown?: boolean; } export const EMPTY_USER_DATA: UserData = { diff --git a/apps/ui/src/data/core/connectors/browser-onboarding-hints.ts b/apps/ui/src/data/core/connectors/browser-onboarding-hints.ts new file mode 100644 index 0000000000..5775e48182 --- /dev/null +++ b/apps/ui/src/data/core/connectors/browser-onboarding-hints.ts @@ -0,0 +1,21 @@ +import type { OnboardingHintsState } from '../types'; + +// Workbench onboarding state. The desktop persists this in appdata via IPC; the +// browser connectors (local + hosted) have no such store, so it lives in +// localStorage, per origin. +const ONBOARDING_HINTS_STORAGE_KEY = 'studio-onboarding-hints'; + +export function readOnboardingHints(): OnboardingHintsState { + try { + const raw = window.localStorage.getItem( ONBOARDING_HINTS_STORAGE_KEY ); + const parsed: unknown = raw ? JSON.parse( raw ) : {}; + return parsed && typeof parsed === 'object' ? ( parsed as OnboardingHintsState ) : {}; + } catch { + return {}; + } +} + +export function writeOnboardingHints( partial: Partial< OnboardingHintsState > ): void { + const merged: OnboardingHintsState = { ...readOnboardingHints(), ...partial }; + window.localStorage.setItem( ONBOARDING_HINTS_STORAGE_KEY, JSON.stringify( merged ) ); +} diff --git a/apps/ui/src/data/core/connectors/hosted/index.ts b/apps/ui/src/data/core/connectors/hosted/index.ts index dd5b90b770..2d036ae910 100644 --- a/apps/ui/src/data/core/connectors/hosted/index.ts +++ b/apps/ui/src/data/core/connectors/hosted/index.ts @@ -1,5 +1,6 @@ import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions'; import { __ } from '@wordpress/i18n'; +import { readOnboardingHints, writeOnboardingHints } from '../browser-onboarding-hints'; import { applyStoredSiteOrder, storeSiteOrder } from '../browser-site-order'; import { UnsupportedError } from '../unsupported-error'; import type { @@ -11,7 +12,6 @@ import type { Connector, InstalledApps, LoadedAiSession, - OnboardingHintsState, SiteDetails, Snapshot, SnapshotUsage, @@ -25,29 +25,6 @@ export interface HostedConnectorOptions { apiBaseUrl: string; } -// Workbench onboarding state persists per origin in the browser surface. -const ONBOARDING_HINTS_STORAGE_KEY = 'studio-onboarding-hints'; - -function readOnboardingHints(): OnboardingHintsState { - try { - const raw = window.localStorage.getItem( ONBOARDING_HINTS_STORAGE_KEY ); - const parsed: unknown = raw ? JSON.parse( raw ) : {}; - return parsed && typeof parsed === 'object' ? ( parsed as OnboardingHintsState ) : {}; - } catch { - return {}; - } -} - -function writeOnboardingHints( partial: Partial< OnboardingHintsState > ): void { - const current = readOnboardingHints(); - const merged: OnboardingHintsState = { - ...current, - ...partial, - completedItems: { ...( current.completedItems ?? {} ), ...( partial.completedItems ?? {} ) }, - }; - window.localStorage.setItem( ONBOARDING_HINTS_STORAGE_KEY, JSON.stringify( merged ) ); -} - // Envelope used by the backend's `/events` SSE stream so a single connection // can carry both agent-run events and session-placement updates. type ServerEvent = diff --git a/apps/ui/src/data/core/connectors/local/index.ts b/apps/ui/src/data/core/connectors/local/index.ts index 23cd28b058..7bf175197f 100644 --- a/apps/ui/src/data/core/connectors/local/index.ts +++ b/apps/ui/src/data/core/connectors/local/index.ts @@ -1,6 +1,7 @@ import { getAuthenticationUrl } from '@studio/common/lib/oauth'; import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions'; import { __ } from '@wordpress/i18n'; +import { readOnboardingHints, writeOnboardingHints } from '../browser-onboarding-hints'; import { applyStoredSiteOrder, storeSiteOrder } from '../browser-site-order'; import { buildPublishCheckoutUrl } from '../publish-checkout-url'; import { UnsupportedError } from '../unsupported-error'; @@ -16,7 +17,6 @@ import type { InstalledApps, LoadedAiSession, LocalMediaFile, - OnboardingHintsState, ProposedSitePath, QuitSitesBehavior, SelectedSiteFolder, @@ -40,28 +40,6 @@ const COLOR_SCHEME_STORAGE_KEY = 'studio-local-color-scheme'; const EDITOR_STORAGE_KEY = 'studio-local-editor'; const TERMINAL_STORAGE_KEY = 'studio-local-terminal'; const QUIT_SITES_BEHAVIOR_STORAGE_KEY = 'studio-local-quit-sites-behavior'; -// Workbench onboarding state persists per origin in the browser surface. -const ONBOARDING_HINTS_STORAGE_KEY = 'studio-onboarding-hints'; - -function readOnboardingHints(): OnboardingHintsState { - try { - const raw = window.localStorage.getItem( ONBOARDING_HINTS_STORAGE_KEY ); - const parsed: unknown = raw ? JSON.parse( raw ) : {}; - return parsed && typeof parsed === 'object' ? ( parsed as OnboardingHintsState ) : {}; - } catch { - return {}; - } -} - -function writeOnboardingHints( partial: Partial< OnboardingHintsState > ): void { - const current = readOnboardingHints(); - const merged: OnboardingHintsState = { - ...current, - ...partial, - completedItems: { ...( current.completedItems ?? {} ), ...( partial.completedItems ?? {} ) }, - }; - window.localStorage.setItem( ONBOARDING_HINTS_STORAGE_KEY, JSON.stringify( merged ) ); -} function parseQuitSitesBehavior( value: string | null ): QuitSitesBehavior | undefined { return value === 'leave-running' || value === 'stop-and-auto-start' || value === 'stop' diff --git a/apps/ui/src/data/core/index.ts b/apps/ui/src/data/core/index.ts index 02c32b660b..5cb776c8af 100644 --- a/apps/ui/src/data/core/index.ts +++ b/apps/ui/src/data/core/index.ts @@ -6,7 +6,6 @@ export type { AppGlobals, AppUpdateStatus, AuthUser, - ChecklistItemId, ColorScheme, Connector, CreateSiteParams, diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 98b4749fec..3c02cc342f 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -424,10 +424,9 @@ export interface Connector { // Switches back to the legacy (classic) Studio UI. disableAgenticUi(): Promise< void >; - // Agentic UI onboarding state (orientation tour + getting-started - // checklist). Distinct from getOnboardingCompleted (the pre-workbench - // first-run welcome flag). setOnboardingHints shallow-merges its partial; - // completedItems is merged by key. Hosted/web persist to localStorage. + // Agentic UI onboarding state. Distinct from getOnboardingCompleted (the + // pre-workbench first-run welcome flag). setOnboardingHints shallow-merges + // its partial. Desktop persists to app.json; hosted/web to localStorage. getOnboardingHints(): Promise< OnboardingHintsState >; setOnboardingHints( partial: Partial< OnboardingHintsState > ): Promise< void >; @@ -446,33 +445,13 @@ export interface AppUpdateStatus { version: string | null; } -// Getting-started checklist item ids. Kept as a closed union so the checklist -// definitions, completion watchers, and persistence all agree on the set. -export type ChecklistItemId = - | 'create-site' - | 'first-agent-edit' - | 'visit-overview' - | 'publish-site' - | 'visit-app-settings' - | 'visit-site-settings'; - // Persisted first-run onboarding state for the workbench. Separate from the -// pre-workbench welcome flag (getOnboardingCompleted) and from dismissed -// messages (which are append-only and so can't model replay/un-dismiss). +// pre-workbench welcome flag (getOnboardingCompleted). export interface OnboardingHintsState { - // Version of the orientation tour the user finished or explicitly skipped. + // Version of the orientation guide the user finished or explicitly skipped. tourCompletedVersion?: number; - // Version of the orientation tour the user closed early (Esc / X). + // Version of the orientation guide the user closed early (Esc / Skip). tourDismissedVersion?: number; - // True once the getting-started checklist has been dismissed. Replay clears - // this — hence it can't ride the append-only dismissedMessages store. - checklistDismissed?: boolean; - // True while the checklist is collapsed to its compact (toast-like) bar. - checklistMinimized?: boolean; - // Completed checklist items → ISO timestamp of completion. - completedItems?: Partial< Record< ChecklistItemId, string > >; - // True once the one-shot publish coachmark has been shown (never re-fires). - publishCoachmarkShown?: boolean; } export interface SnapshotUsage { diff --git a/apps/ui/src/data/queries/use-onboarding-hints.ts b/apps/ui/src/data/queries/use-onboarding-hints.ts index c21f600e4c..130e605cf3 100644 --- a/apps/ui/src/data/queries/use-onboarding-hints.ts +++ b/apps/ui/src/data/queries/use-onboarding-hints.ts @@ -31,22 +31,6 @@ export function useOnboardingCompleted() { } ); } -// Shallow-merge a partial into the cached hints, merging completedItems by key -// so a checklist completion never clobbers a concurrent one. -function mergeHints( - current: OnboardingHintsState | undefined, - partial: Partial< OnboardingHintsState > -): OnboardingHintsState { - return { - ...( current ?? {} ), - ...partial, - completedItems: { - ...( current?.completedItems ?? {} ), - ...( partial.completedItems ?? {} ), - }, - }; -} - export function useSetOnboardingHints() { const connector = useConnector(); const queryClient = useQueryClient(); @@ -58,7 +42,7 @@ export function useSetOnboardingHints() { onMutate: ( partial ) => { queryClient.setQueryData( ONBOARDING_HINTS_QUERY_KEY, - ( current: OnboardingHintsState | undefined ) => mergeHints( current, partial ) + ( current: OnboardingHintsState | undefined ) => ( { ...( current ?? {} ), ...partial } ) ); }, } ); From 938d2886e41397f3a79cbaceab6a52dfcabe85e0 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Sat, 1 Aug 2026 12:01:01 +0100 Subject: [PATCH 3/7] Adapt the orientation guide to new/migrating users and Studio Code availability --- apps/studio/src/ipc-handlers.ts | 5 + .../modules/user-settings/lib/ipc-handlers.ts | 22 ++- apps/studio/src/storage/storage-types.ts | 1 + apps/ui/src/data/core/types.ts | 4 + .../src/data/onboarding/orientation-guide.ts | 141 ++++++++++-------- .../use-orientation-autostart.test.ts | 22 ++- .../onboarding/use-orientation-autostart.ts | 8 +- .../data/onboarding/use-orientation-replay.ts | 18 ++- 8 files changed, 138 insertions(+), 83 deletions(-) diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 4fdf88210f..4659c857bd 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -167,6 +167,7 @@ import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers'; import { STABLE_BIN_DIR_PATH } from 'src/modules/cli/lib/windows-installation-manager'; import { supportedEditorConfig, SupportedEditor } from 'src/modules/user-settings/lib/editor'; import { + recordAgenticUiMigration, getUserEditor, getUserTerminal, getDefaultSiteDirectory, @@ -1533,6 +1534,10 @@ export async function getBetaFeatures( _event: IpcMainInvokeEvent ): Promise< Be export async function enableAgenticUi( _event: IpcMainInvokeEvent ): Promise< void > { await updateBetaFeatureInLib( 'enableAgenticUi', true ); setAgenticUiEnabled( true ); + // Opting in from classic Studio is the sole way an existing user reaches the + // agentic workbench, so record it here for the orientation guide's migrating + // copy. Must land before the renderer reloads below so the guide sees it. + await recordAgenticUiMigration(); const mainWindow = await getMainWindow(); if ( mainWindow && ! mainWindow.isDestroyed() ) { await loadMainWindowRenderer( mainWindow ); diff --git a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts index 7f0e37b5e5..f18e7b2b5b 100644 --- a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts @@ -196,17 +196,14 @@ export async function getWapuuScore(): Promise< number | undefined > { return userData.wapuuScore; } -// Agentic UI onboarding state (orientation tour, getting-started checklist). +// Agentic UI onboarding state (orientation guide seen-state, migration marker). // The blob is opaque to the desktop; the renderer owns its meaning. export async function getOnboardingHints(): Promise< OnboardingHintsState > { const userData = await loadUserData(); return userData.onboardingHints ?? {}; } -export async function saveOnboardingHints( - _event: IpcMainInvokeEvent, - partial: Partial< OnboardingHintsState > -): Promise< void > { +async function persistOnboardingHints( partial: Partial< OnboardingHintsState > ): Promise< void > { if ( ! partial || typeof partial !== 'object' ) { return; } @@ -220,6 +217,21 @@ export async function saveOnboardingHints( } } +export async function saveOnboardingHints( + _event: IpcMainInvokeEvent, + partial: Partial< OnboardingHintsState > +): Promise< void > { + await persistOnboardingHints( partial ); +} + +// Marks that the user reached the agentic workbench by opting in from classic +// Studio, so the orientation guide can greet them as a migrating user. Fresh +// installs get the agentic UI seeded on by default (migration 09) and never +// hit this path, so they stay "new". +export async function recordAgenticUiMigration(): Promise< void > { + await persistOnboardingHints( { migratedFromClassic: true } ); +} + export async function getGlobalAgentInstructions(): Promise< string > { return ( await readGlobalInstructionsFile() ) ?? ''; } diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index 39ea366d27..d576f5d91d 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -73,6 +73,7 @@ export interface PromptWindowsSpeedUpResult { export interface OnboardingHintsState { tourCompletedVersion?: number; tourDismissedVersion?: number; + migratedFromClassic?: boolean; } export const EMPTY_USER_DATA: UserData = { diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 4c6c42b79b..1a41154bc2 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -474,6 +474,10 @@ export interface OnboardingHintsState { tourCompletedVersion?: number; // Version of the orientation guide the user closed early (Esc / Skip). tourDismissedVersion?: number; + // True when the user reached the agentic workbench by opting in from classic + // Studio (vs a fresh install that starts here). Drives the guide's first-page + // "Welcome to WordPress Studio 2.0" migrating copy. + migratedFromClassic?: boolean; } export interface SnapshotUsage { diff --git a/apps/ui/src/data/onboarding/orientation-guide.ts b/apps/ui/src/data/onboarding/orientation-guide.ts index 50d425eab4..9abf509b51 100644 --- a/apps/ui/src/data/onboarding/orientation-guide.ts +++ b/apps/ui/src/data/onboarding/orientation-guide.ts @@ -3,9 +3,17 @@ import { __ } from '@wordpress/i18n'; // Bump to re-show the orientation guide to everyone who saw the previous // version (compared against OnboardingHintsState.tourCompletedVersion / // tourDismissedVersion — the field names predate the modal and stay generic). -export const ORIENTATION_GUIDE_VERSION = 1; +export const ORIENTATION_GUIDE_VERSION = 2; -export type OrientationVariant = 'agentic' | 'overview'; +// Two independent axes drive the copy (see the Welcome Tour Figma): +// migrating — coming from classic Studio vs a fresh install (page 1 only) +// chatEnabled — whether Studio Code (chat) is on offer (pages 2 and 3). The +// design labels this "signed in", but signed-out, offline, and +// opted-out (Settings → AI) all collapse to the non-chat copy. +export interface OrientationVariant { + migrating: boolean; + chatEnabled: boolean; +} export type OrientationIllustrationId = 'sites' | 'chat' | 'preview' | 'overview'; @@ -13,79 +21,94 @@ export interface GuidePage { illustration: OrientationIllustrationId; title: () => string; description: () => string; - // The advance button's label — the user's reaction to this page ("Cool", - // "Wait, really?"), not a generic "Next". + // The advance button's label. action: () => string; } export interface GuideDefinition { - id: OrientationVariant; - version: number; pages: GuidePage[]; } -const SITES_PAGE: GuidePage = { - illustration: 'sites', - title: () => __( 'Welcome to your workbench' ), - description: () => - __( - 'Every site you build lives in the sidebar on the left. Switch between them anytime — each keeps its own history.' - ), - action: () => __( 'Cool' ), -}; - -// Final page in both variants, so its action closes the guide. -const PREVIEW_PAGE: GuidePage = { - illustration: 'preview', - title: () => __( 'See it live' ), - description: () => - __( - 'Your site previews on the right and updates as changes land. Switch between the front end, WP Admin, and the database from the toolbar.' - ), - action: () => __( 'Let’s go' ), -}; +// Page 1 — the sidebar. Differs only by new vs migrating; a migrating user gets +// reassured their existing sites carried over. +function sitesPage( migrating: boolean ): GuidePage { + if ( migrating ) { + return { + illustration: 'sites', + title: () => __( 'Welcome to WordPress Studio 2.0' ), + description: () => + __( + 'Everything you built in Studio is right here in the sidebar. Same sites, same files with a new workbench around them.' + ), + action: () => __( 'Next' ), + }; + } + return { + illustration: 'sites', + title: () => __( 'Welcome to WordPress Studio' ), + description: () => + __( + 'Every site you build lives in the sidebar on the left. Switch between them anytime. The sidebar is where you’ll find site settings and a quick way to start and stop your site.' + ), + action: () => __( 'Next' ), + }; +} -// Agentic variant: the user lands in a chat session, so the middle beat is the -// agent. Ends on the preview so the connection between asking and seeing is -// the last thing they read before their first prompt. -export const AGENTIC_ORIENTATION_GUIDE: GuideDefinition = { - id: 'agentic', - version: ORIENTATION_GUIDE_VERSION, - pages: [ - SITES_PAGE, - { +// Page 2 — the middle beat. Signed-in users learn about building with Studio +// Code; signed-out users learn about the site overview control panel. +function workspacePage( chatEnabled: boolean ): GuidePage { + if ( chatEnabled ) { + return { illustration: 'chat', title: () => __( 'Build by asking' ), description: () => __( - 'Describe what you want in plain language — add a page, change the design, fix a bug — and the agent builds it. Start from a suggestion or just type.' + 'Describe what you want in plain language. Add a page, change the design, fix a bug — and our AI agent, Studio Code, builds it.' ), - action: () => __( 'Wait, really?' ), - }, - PREVIEW_PAGE, - ], -}; + action: () => __( 'Next' ), + }; + } + return { + illustration: 'overview', + title: () => __( 'Manage your site' ), + description: () => + __( + 'The site overview is your control panel — open the editor and admin tools, or duplicate, export, and manage your site from one place.' + ), + action: () => __( 'Next' ), + }; +} -// Non-agentic variant: no chat. The middle beat is the site overview and its -// customize/manage tools. -export const OVERVIEW_ORIENTATION_GUIDE: GuideDefinition = { - id: 'overview', - version: ORIENTATION_GUIDE_VERSION, - pages: [ - SITES_PAGE, - { - illustration: 'overview', - title: () => __( 'Manage your site' ), +// Page 3 — the live preview. Signed-in copy calls out the realtime refresh that +// comes with Studio Code edits; signed-out copy is the plain preview. +function previewPage( chatEnabled: boolean ): GuidePage { + if ( chatEnabled ) { + return { + illustration: 'preview', + title: () => __( 'See your site update in realtime' ), description: () => __( - 'The site overview is your control panel — open the editor and admin tools, or duplicate, export, and manage your site from one place.' + 'Your site is shown on the right, automatically refreshing as you make changes with Studio Code. Switch between the front-end, wp-admin, and the database from the toolbar.' ), - action: () => __( 'That’s handy' ), - }, - PREVIEW_PAGE, - ], -}; + action: () => __( 'Let’s go' ), + }; + } + return { + illustration: 'preview', + title: () => __( 'See your site inline' ), + description: () => + __( + 'Your site is shown on the right. Switch between the front-end, wp-admin, and the database from the toolbar.' + ), + action: () => __( 'Let’s go' ), + }; +} -export function getOrientationGuide( variant: OrientationVariant ): GuideDefinition { - return variant === 'agentic' ? AGENTIC_ORIENTATION_GUIDE : OVERVIEW_ORIENTATION_GUIDE; +export function getOrientationGuide( { + migrating, + chatEnabled, +}: OrientationVariant ): GuideDefinition { + return { + pages: [ sitesPage( migrating ), workspacePage( chatEnabled ), previewPage( chatEnabled ) ], + }; } diff --git a/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts b/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts index 5eec32fb12..c515049dfd 100644 --- a/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts +++ b/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts @@ -6,21 +6,27 @@ import type { OnboardingHintsState } from '@/data/core'; const base = { onboardingCompleted: true, siteCount: 1, - agentic: { enabled: true, isReady: true }, + agentic: { chatEnabled: true, isReady: true }, hints: {} as OnboardingHintsState, guideOpen: false, alreadyStarted: false, }; describe( 'deriveOrientationAutostart', () => { - it( 'opens the agentic guide when everything is ready', () => { - expect( deriveOrientationAutostart( base ) ).toBe( 'agentic' ); + it( 'opens the chat guide for a fresh install when everything is ready', () => { + expect( deriveOrientationAutostart( base ) ).toEqual( { migrating: false, chatEnabled: true } ); } ); - it( 'opens the overview guide when agentic features are disabled', () => { + it( 'marks the non-chat variant when chat is unavailable (signed out, offline, or opted out)', () => { expect( - deriveOrientationAutostart( { ...base, agentic: { enabled: false, isReady: true } } ) - ).toBe( 'overview' ); + deriveOrientationAutostart( { ...base, agentic: { chatEnabled: false, isReady: true } } ) + ).toEqual( { migrating: false, chatEnabled: false } ); + } ); + + it( 'marks the migrating variant when the user opted in from classic', () => { + expect( + deriveOrientationAutostart( { ...base, hints: { migratedFromClassic: true } } ) + ).toEqual( { migrating: true, chatEnabled: true } ); } ); it( 'waits until the pre-workbench welcome is done', () => { @@ -34,7 +40,7 @@ describe( 'deriveOrientationAutostart', () => { it( 'waits until the agentic gate has resolved', () => { expect( - deriveOrientationAutostart( { ...base, agentic: { enabled: true, isReady: false } } ) + deriveOrientationAutostart( { ...base, agentic: { chatEnabled: true, isReady: false } } ) ).toBeNull(); } ); @@ -66,7 +72,7 @@ describe( 'deriveOrientationAutostart', () => { ...base, hints: { tourCompletedVersion: ORIENTATION_GUIDE_VERSION - 1 }, } ) - ).toBe( 'agentic' ); + ).toEqual( { migrating: false, chatEnabled: true } ); } ); it( 'does not open while the guide is already open or already started', () => { diff --git a/apps/ui/src/data/onboarding/use-orientation-autostart.ts b/apps/ui/src/data/onboarding/use-orientation-autostart.ts index 2a8696a6a3..1a3eb6dc4a 100644 --- a/apps/ui/src/data/onboarding/use-orientation-autostart.ts +++ b/apps/ui/src/data/onboarding/use-orientation-autostart.ts @@ -14,7 +14,7 @@ import type { OnboardingHintsState } from '@/data/core'; interface AutostartInputs { onboardingCompleted: boolean | undefined; siteCount: number; - agentic: { enabled: boolean; isReady: boolean }; + agentic: { chatEnabled: boolean; isReady: boolean }; hints: OnboardingHintsState | undefined; guideOpen: boolean; alreadyStarted: boolean; @@ -49,7 +49,7 @@ export function deriveOrientationAutostart( { if ( seen ) { return null; } - return agentic.enabled ? 'agentic' : 'overview'; + return { migrating: hints.migratedFromClassic ?? false, chatEnabled: agentic.chatEnabled }; } // Let the workbench render and settle before the guide appears, so it reads as @@ -76,7 +76,7 @@ export function useOrientationAutostart(): void { const variant = deriveOrientationAutostart( { onboardingCompleted, siteCount: sites?.length ?? 0, - agentic: { enabled: agentic.enabled, isReady: agentic.isReady }, + agentic: { chatEnabled: agentic.chatEnabled, isReady: agentic.isReady }, hints, guideOpen: isOpen, alreadyStarted: startedRef.current, @@ -104,7 +104,7 @@ export function useOrientationAutostart(): void { }, [ onboardingCompleted, sites?.length, - agentic.enabled, + agentic.chatEnabled, agentic.isReady, hints, isOpen, diff --git a/apps/ui/src/data/onboarding/use-orientation-replay.ts b/apps/ui/src/data/onboarding/use-orientation-replay.ts index aa9fe5523e..1212f92ec5 100644 --- a/apps/ui/src/data/onboarding/use-orientation-replay.ts +++ b/apps/ui/src/data/onboarding/use-orientation-replay.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { useOnboardingGuide } from '@/components/onboarding-guide/use-onboarding-guide'; import { useConnector } from '@/data/core'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; -import { useSetOnboardingHints } from '@/data/queries/use-onboarding-hints'; +import { useOnboardingHints, useSetOnboardingHints } from '@/data/queries/use-onboarding-hints'; import { ORIENTATION_GUIDE_VERSION } from './orientation-guide'; /** @@ -15,19 +15,23 @@ import { ORIENTATION_GUIDE_VERSION } from './orientation-guide'; export function useOrientationReplay(): void { const connector = useConnector(); const agentic = useAgenticFeatures(); + const { data: hints } = useOnboardingHints(); const setHints = useSetOnboardingHints(); const { openGuide } = useOnboardingGuide(); - // The menu event fires outside React's data flow, so read the latest gate - // through a ref instead of resubscribing every time it changes. - const enabledRef = useRef( agentic.enabled ); + // The menu event fires outside React's data flow, so read the latest variant + // inputs through a ref instead of resubscribing every time they change. + const variantRef = useRef( { migrating: false, chatEnabled: agentic.chatEnabled } ); useEffect( () => { - enabledRef.current = agentic.enabled; - }, [ agentic.enabled ] ); + variantRef.current = { + migrating: hints?.migratedFromClassic ?? false, + chatEnabled: agentic.chatEnabled, + }; + }, [ agentic.chatEnabled, hints?.migratedFromClassic ] ); useEffect( () => { return connector.onShowGettingStarted( () => { - openGuide( enabledRef.current ? 'agentic' : 'overview', { + openGuide( variantRef.current, { onEnd: ( reason ) => { setHints.mutate( reason === 'completed' From 73a56fa7794ba42ba5998b1907708e71bbfc3e21 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 4 Aug 2026 18:23:48 +0100 Subject: [PATCH 4/7] Make the orientation guide RTL-aware and annotate directional copy --- apps/ui/src/components/onboarding-guide/style.module.css | 4 ++-- apps/ui/src/data/onboarding/orientation-guide.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/onboarding-guide/style.module.css b/apps/ui/src/components/onboarding-guide/style.module.css index 3e640bbade..33f923d52d 100644 --- a/apps/ui/src/components/onboarding-guide/style.module.css +++ b/apps/ui/src/components/onboarding-guide/style.module.css @@ -27,7 +27,7 @@ .close { position: absolute; top: var(--wpds-dimension-padding-sm); - right: var(--wpds-dimension-padding-sm); + inset-inline-end: var(--wpds-dimension-padding-sm); z-index: 1; } @@ -41,7 +41,7 @@ box-sizing: border-box; padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-lg); - text-align: left; + text-align: start; } .title { diff --git a/apps/ui/src/data/onboarding/orientation-guide.ts b/apps/ui/src/data/onboarding/orientation-guide.ts index 9abf509b51..5cd8df5b01 100644 --- a/apps/ui/src/data/onboarding/orientation-guide.ts +++ b/apps/ui/src/data/onboarding/orientation-guide.ts @@ -47,6 +47,7 @@ function sitesPage( migrating: boolean ): GuidePage { illustration: 'sites', title: () => __( 'Welcome to WordPress Studio' ), description: () => + /* translators: "on the left" describes the sidebar in left-to-right layouts; in right-to-left languages it sits on the right — adapt the direction accordingly. */ __( 'Every site you build lives in the sidebar on the left. Switch between them anytime. The sidebar is where you’ll find site settings and a quick way to start and stop your site.' ), @@ -87,6 +88,7 @@ function previewPage( chatEnabled: boolean ): GuidePage { illustration: 'preview', title: () => __( 'See your site update in realtime' ), description: () => + /* translators: "on the right" describes the preview in left-to-right layouts; in right-to-left languages it sits on the left — adapt the direction accordingly. */ __( 'Your site is shown on the right, automatically refreshing as you make changes with Studio Code. Switch between the front-end, wp-admin, and the database from the toolbar.' ), @@ -97,6 +99,7 @@ function previewPage( chatEnabled: boolean ): GuidePage { illustration: 'preview', title: () => __( 'See your site inline' ), description: () => + /* translators: "on the right" describes the preview in left-to-right layouts; in right-to-left languages it sits on the left — adapt the direction accordingly. */ __( 'Your site is shown on the right. Switch between the front-end, wp-admin, and the database from the toolbar.' ), From 5c9cf83554c748a27807e7f7f1b50a8493fdc477 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 5 Aug 2026 16:53:35 +0100 Subject: [PATCH 5/7] Make the tour modal a window-drag handle and fix pager dot radius --- .../src/components/onboarding-guide/style.module.css | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/onboarding-guide/style.module.css b/apps/ui/src/components/onboarding-guide/style.module.css index 33f923d52d..25a7e08b95 100644 --- a/apps/ui/src/components/onboarding-guide/style.module.css +++ b/apps/ui/src/components/onboarding-guide/style.module.css @@ -22,6 +22,9 @@ flex-shrink: 0; background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 8%, transparent); border-bottom: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak); + /* The modal backdrop covers the window's usual drag region, so let the + header act as a drag handle to keep the app window movable while it's open. */ + -webkit-app-region: drag; } .close { @@ -29,6 +32,7 @@ top: var(--wpds-dimension-padding-sm); inset-inline-end: var(--wpds-dimension-padding-sm); z-index: 1; + -webkit-app-region: no-drag; } .content { @@ -90,14 +94,16 @@ .dot { width: 6px; height: 6px; - border-radius: 50%; + /* Fixed radius (not 50%) so it stays constant as the active dot stretches + to a pill — a percentage radius warps mid-transition. At 6px this still + renders a full circle. */ + border-radius: 3px; background: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 22%, transparent); transition: background 160ms ease, width 160ms ease; } .dotActive { width: 18px; - border-radius: 3px; background: var(--wpds-color-fg-interactive-brand); } From d2d17a03706b94134100a7a2e889033c25752a58 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 5 Aug 2026 17:06:09 +0100 Subject: [PATCH 6/7] Drag the tour window via the backdrop, not the transformed popup --- .../ui/src/components/onboarding-guide/index.tsx | 2 +- .../components/onboarding-guide/style.module.css | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/ui/src/components/onboarding-guide/index.tsx b/apps/ui/src/components/onboarding-guide/index.tsx index 309032131d..66189938eb 100644 --- a/apps/ui/src/components/onboarding-guide/index.tsx +++ b/apps/ui/src/components/onboarding-guide/index.tsx @@ -47,7 +47,7 @@ export function OnboardingGuide( { guide, onComplete, onDismiss }: OnboardingGui } } } > - + diff --git a/apps/ui/src/components/onboarding-guide/style.module.css b/apps/ui/src/components/onboarding-guide/style.module.css index 25a7e08b95..86b3a1c2dc 100644 --- a/apps/ui/src/components/onboarding-guide/style.module.css +++ b/apps/ui/src/components/onboarding-guide/style.module.css @@ -22,9 +22,6 @@ flex-shrink: 0; background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 8%, transparent); border-bottom: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak); - /* The modal backdrop covers the window's usual drag region, so let the - header act as a drag handle to keep the app window movable while it's open. */ - -webkit-app-region: drag; } .close { @@ -32,7 +29,18 @@ top: var(--wpds-dimension-padding-sm); inset-inline-end: var(--wpds-dimension-padding-sm); z-index: 1; - -webkit-app-region: no-drag; +} + +/* The modal backdrop covers the window's usual drag region, so nothing is + movable while the tour is open. The popup itself can't be a drag handle — + it's centered with a CSS transform, and Chromium ignores -webkit-app-region + under a transformed ancestor — but the backdrop isn't transformed, so make it + the drag surface. Scoped to this guide's backdrop (the sibling preceding our + popup) so every other dialog keeps its click-outside-to-dismiss. */ +:global( + [data-testid='dialog-backdrop']:has(~ [data-orientation-guide], ~ * [data-orientation-guide]) +) { + -webkit-app-region: drag; } .content { From 2830a95f18cb67fd5e699a2766c364216ce17a73 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Fri, 7 Aug 2026 15:04:40 -0400 Subject: [PATCH 7/7] Agentic UI: Sites and Chat illustrations for the orientation guide (#4441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Related issues - Part of [STU-2016](https://linear.app/automattic/issue/STU-2016) - Stacked on #4331 (base branch is `stu-2016-add-orientation-guide`); retarget to `trunk` once that merges. > ⚠️ Visual change: needs human review in light + dark mode. ## How AI was used in this PR Built and iterated interactively with Claude Code. ## Proposed Changes image image Replaces the orientation guide's placeholder header art with animated illustrations that *show* the workbench rather than describe it. - **Page 1 (sites):** a real-looking sidebar row where a cursor drifts in, hovers the site-overview button, then clicks the status button to start the site — stopped → starting → running — with the tooltips it would really show. Loops. - **Page 2, signed in (chat):** a one-time playback of Studio Code — the composer types a prompt, sends it (dropping off-stage and flipping to its busy state) as the prompt becomes a user bubble, the reply streams in, and a tool call appears and keeps working. A subtle Replay control restarts it. Both are theme-adaptive and respect reduced motion. They're built as a small reusable system (a `Stage` + `Cursor` and an id-keyed scene registry) so more scenes can be added without bespoke plumbing. The "manage your site" (overview) and preview illustrations remain placeholders for follow-up. ## Testing Instructions 1. `npm run cli:build:ui` (the agentic UI isn't rebuilt by the normal dev watcher). 2. Enable the **Agentic UI** beta and open a site's workbench. 3. **Help ▸ Getting Started** — page 1 shows the sites animation. 4. Signed in with Studio Code on, page 2 shows the chat animation; the Replay control (top-left) restarts it. 5. Verify in **light and dark**, and with **reduced motion** enabled (scenes settle on a clean static frame). ## Pre-merge Checklist - [x] TypeScript / lint / build pass for the changed files. - [ ] Human visual review in light + dark mode. --------- Co-authored-by: Claude Opus 4.8 --- .../onboarding-guide/illustrations.tsx | 9 - .../onboarding-guide/illustrations/chat.tsx | 154 +++++++ .../illustrations/choreography.ts | 168 +++++++ .../onboarding-guide/illustrations/index.tsx | 27 ++ .../illustrations/primitives.tsx | 85 ++++ .../onboarding-guide/illustrations/sites.tsx | 170 +++++++ .../illustrations/style.module.css | 421 ++++++++++++++++++ .../src/components/onboarding-guide/index.tsx | 10 +- .../onboarding-guide/style.module.css | 22 +- 9 files changed, 1041 insertions(+), 25 deletions(-) delete mode 100644 apps/ui/src/components/onboarding-guide/illustrations.tsx create mode 100644 apps/ui/src/components/onboarding-guide/illustrations/chat.tsx create mode 100644 apps/ui/src/components/onboarding-guide/illustrations/choreography.ts create mode 100644 apps/ui/src/components/onboarding-guide/illustrations/index.tsx create mode 100644 apps/ui/src/components/onboarding-guide/illustrations/primitives.tsx create mode 100644 apps/ui/src/components/onboarding-guide/illustrations/sites.tsx create mode 100644 apps/ui/src/components/onboarding-guide/illustrations/style.module.css diff --git a/apps/ui/src/components/onboarding-guide/illustrations.tsx b/apps/ui/src/components/onboarding-guide/illustrations.tsx deleted file mode 100644 index 4d20f7faa6..0000000000 --- a/apps/ui/src/components/onboarding-guide/illustrations.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import styles from './style.module.css'; -import type { OrientationIllustrationId } from '@/data/onboarding/orientation-guide'; - -// Placeholder for the guide's header art. Real illustrations (keyed by the -// page's illustration id) drop in here; until then this is just the tinted -// slot at the correct size. -export function OrientationIllustration( { id }: { id: OrientationIllustrationId } ) { - return
; -} diff --git a/apps/ui/src/components/onboarding-guide/illustrations/chat.tsx b/apps/ui/src/components/onboarding-guide/illustrations/chat.tsx new file mode 100644 index 0000000000..fbc41fff2e --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/illustrations/chat.tsx @@ -0,0 +1,154 @@ +import { __ } from '@wordpress/i18n'; +import { clsx } from 'clsx'; +import { at, easings, envelope, span, useTimeline } from './choreography'; +import { StreamingText } from './primitives'; +import styles from './style.module.css'; + +function PlusGlyph() { + return ( + + ); +} + +function SendGlyph() { + return ( + + ); +} + +function FileGlyph() { + return ( + + ); +} + +const TYPE_MS = 42; +const STREAM_MS = 17; + +// Page 2 (signed in) — Studio Code. A one-time playback: type a prompt, send it +// (the composer slides off-stage and the prompt becomes a user bubble), stream +// the reply, then show a tool call that keeps "reading" and a Replay control. +// Everything derives from the timeline clock `t`; theme-adaptive. +export function ChatIllustration() { + const prompt = __( 'Add a contact form to the homepage' ); + const reply = __( + 'Good idea! I’ll open the homepage and check to see where the best place would be for a contact form.' + ); + + // Cue marks (ms), derived from the copy lengths so translations stay in sync. + const typeEnd = 550 + prompt.length * TYPE_MS; + const sentAt = typeEnd + 480; + const streamStart = sentAt + 680; + const streamEnd = streamStart + reply.length * STREAM_MS; + const toolAt = streamEnd + 300; + const replayAt = streamEnd + 800; + + const { t, restart } = useTimeline( { duration: replayAt + 900, loop: false } ); + + const promptLen = Math.floor( span( t, 550, typeEnd, easings.linear ) * prompt.length ); + const replyLen = Math.floor( span( t, streamStart, streamEnd, easings.linear ) * reply.length ); + const sent = at( t, sentAt ); + const typing = ! sent && promptLen > 0; + const showPlaceholder = sent || promptLen === 0; + const placeholder = sent + ? __( 'Queue the next message while I work…' ) + : __( 'What can Studio build today?' ); + + // Composer: centered → slides down and off-stage on send. + const composerY = -54 + span( t, sentAt, sentAt + 800, easings.easeInOut ) * 186; + // Bubble: scales/fades up from the composer into place. + const bubbleP = span( t, sentAt, sentAt + 520, easings.easeOut ); + const assistantOp = span( t, sentAt, sentAt + 300, easings.easeOut ); + const toolOp = envelope( t, toolAt, 300 ); + const replayOp = span( t, replayAt, replayAt + 300, easings.easeOut ); + + return ( +
+ +
+
+ { prompt } +
+
+ +
+ + + { __( 'Reading' ) } front-page.php + +
+
+
+ { showPlaceholder ? ( + { placeholder } + ) : null } + { typing ? ( + + ) : null } +
+
+ + + +
+ { sent ? ( + + + + ) : null } + 0 ) && styles.sendButtonActive + ) } + > + + +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/onboarding-guide/illustrations/choreography.ts b/apps/ui/src/components/onboarding-guide/illustrations/choreography.ts new file mode 100644 index 0000000000..952f615dd9 --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/illustrations/choreography.ts @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +// The choreography system for the onboarding illustrations. Every scene runs on +// one clock: `useTimeline` exposes elapsed time `t` (ms), and the pure helpers +// below turn `t` into styles. Loops and one-shots, reduced motion, and replay +// are all handled here so scenes only declare *what* happens *when*. + +export type Easing = ( p: number ) => number; + +export const easings = { + linear: ( p: number ) => p, + easeIn: ( p: number ) => p * p * p, + easeOut: ( p: number ) => 1 - Math.pow( 1 - p, 3 ), + easeInOut: ( p: number ) => ( p < 0.5 ? 4 * p * p * p : 1 - Math.pow( -2 * p + 2, 3 ) / 2 ), +}; + +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + Boolean( window.matchMedia?.( '(prefers-reduced-motion: reduce)' ).matches ) + ); +} + +export interface Timeline { + /** Elapsed milliseconds: wraps at `duration` when looping, clamps otherwise. */ + t: number; + /** Restart from zero (used by one-shot scenes' Replay control). */ + restart: () => void; +} + +// A single requestAnimationFrame clock. Reduced motion skips the animation and +// rests on a representative frame — the start of a loop, or the end of a +// one-shot (its finished state). +export function useTimeline( { + duration, + loop = false, +}: { + duration: number; + loop?: boolean; +} ): Timeline { + const [ t, setT ] = useState( () => ( ! loop && prefersReducedMotion() ? duration : 0 ) ); + const [ nonce, setNonce ] = useState( 0 ); + const rafRef = useRef( 0 ); + + const restart = useCallback( () => setNonce( ( n ) => n + 1 ), [] ); + + useEffect( () => { + if ( prefersReducedMotion() ) { + setT( loop ? 0 : duration ); + return; + } + let start = 0; + const tick = ( now: number ) => { + if ( ! start ) { + start = now; + } + const elapsed = now - start; + setT( loop ? elapsed % duration : Math.min( elapsed, duration ) ); + if ( loop || elapsed < duration ) { + rafRef.current = requestAnimationFrame( tick ); + } + }; + rafRef.current = requestAnimationFrame( tick ); + return () => cancelAnimationFrame( rafRef.current ); + }, [ duration, loop, nonce ] ); + + return { t, restart }; +} + +// Has the clock passed a cue mark? +export function at( t: number, mark: number ): boolean { + return t >= mark; +} + +// Eased 0→1 progress across a window (before → 0, after → 1). +export function span( + t: number, + from: number, + to: number, + easing: Easing = easings.easeInOut +): number { + if ( t <= from ) { + return 0; + } + if ( t >= to ) { + return 1; + } + return easing( ( t - from ) / ( to - from ) ); +} + +// Opacity envelope: fade in over `inDur` at `inAt`, hold, then (optionally) fade +// out over `outDur` at `outAt`. Returns 0→1. +export function envelope( + t: number, + inAt: number, + inDur: number, + outAt = Infinity, + outDur = 0 +): number { + if ( t < inAt ) { + return 0; + } + if ( t < inAt + inDur ) { + return ( t - inAt ) / inDur; + } + if ( t < outAt ) { + return 1; + } + if ( t < outAt + outDur ) { + return 1 - ( t - outAt ) / outDur; + } + return 0; +} + +export interface Keyframe { + /** Time (ms) of this keyframe. */ + at: number; + /** Easing for the segment starting at this keyframe. */ + ease?: Easing; + [ prop: string ]: number | Easing | undefined; +} + +// Interpolates numeric properties across keyframes at time `t`. Any numeric key +// present on the frames (x, y, scale, opacity, …) is tweened; the segment uses +// the starting frame's `ease` (default easeInOut). Frames must be ordered by +// `at`. +export function sample( t: number, frames: Keyframe[] ): Record< string, number > { + const readNumbers = ( frame: Keyframe ): Record< string, number > => { + const out: Record< string, number > = {}; + for ( const key of Object.keys( frame ) ) { + if ( key === 'at' || key === 'ease' ) { + continue; + } + const value = frame[ key ]; + if ( typeof value === 'number' ) { + out[ key ] = value; + } + } + return out; + }; + + if ( t <= frames[ 0 ].at ) { + return readNumbers( frames[ 0 ] ); + } + const last = frames[ frames.length - 1 ]; + if ( t >= last.at ) { + return readNumbers( last ); + } + + let i = 0; + while ( i < frames.length - 1 && t > frames[ i + 1 ].at ) { + i++; + } + const a = frames[ i ]; + const b = frames[ i + 1 ]; + const ease = a.ease ?? easings.easeInOut; + const p = ease( ( t - a.at ) / ( b.at - a.at ) ); + + const from = readNumbers( a ); + const to = readNumbers( b ); + const out: Record< string, number > = {}; + for ( const key of Object.keys( from ) ) { + const av = from[ key ]; + const bv = to[ key ] ?? av; + out[ key ] = av + ( bv - av ) * p; + } + return out; +} diff --git a/apps/ui/src/components/onboarding-guide/illustrations/index.tsx b/apps/ui/src/components/onboarding-guide/illustrations/index.tsx new file mode 100644 index 0000000000..2fcc12f684 --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/illustrations/index.tsx @@ -0,0 +1,27 @@ +import { ChatIllustration } from './chat'; +import { Stage } from './primitives'; +import { SitesIllustration } from './sites'; +import styles from './style.module.css'; +import type { OrientationIllustrationId } from '@/data/onboarding/orientation-guide'; +import type { ComponentType } from 'react'; + +// Each page's illustration is a self-contained animated scene registered by id. +// Ids without a scene yet fall back to a plain tinted slot, so the guide always +// renders while the remaining scenes are built. +const SCENES: Partial< Record< OrientationIllustrationId, ComponentType > > = { + sites: SitesIllustration, + chat: ChatIllustration, +}; + +// Illustrations with a dark full-bleed background; the modal uses this to flip +// the overlaid close (Skip) button to a light icon so it stays legible. +const DARK_ILLUSTRATIONS: ReadonlySet< OrientationIllustrationId > = new Set( [ 'sites' ] ); + +export function isDarkOrientationIllustration( id: OrientationIllustrationId ): boolean { + return DARK_ILLUSTRATIONS.has( id ); +} + +export function OrientationIllustration( { id }: { id: OrientationIllustrationId } ) { + const Scene = SCENES[ id ]; + return { Scene ? :
}; +} diff --git a/apps/ui/src/components/onboarding-guide/illustrations/primitives.tsx b/apps/ui/src/components/onboarding-guide/illustrations/primitives.tsx new file mode 100644 index 0000000000..e45b355f0f --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/illustrations/primitives.tsx @@ -0,0 +1,85 @@ +import { clsx } from 'clsx'; +import styles from './style.module.css'; +import type { OrientationIllustrationId } from '@/data/onboarding/orientation-guide'; +import type { CSSProperties, ReactNode } from 'react'; + +// Shared building blocks for the illustration scenes. Motion is never baked in +// here — each primitive takes a `style` the scene computes from the timeline, so +// the same pieces compose into any choreography. + +// The full-bleed header slot every illustration fills: a fixed aspect ratio so +// the popup header keeps a stable height, and a positioning context for the +// scene. Each scene owns its own background and (if needed) color-scheme scope. +export function Stage( { id, children }: { id: OrientationIllustrationId; children: ReactNode } ) { + return ( +
+ { children } +
+ ); +} + +// A pointer used to demonstrate interactions. Position/scale/opacity come from +// the scene via `style`. The macOS look — black fill, thick rounded white +// border — reads clearly on any background. +export function Cursor( { className, style }: { className?: string; style?: CSSProperties } ) { + return ( + + ); +} + +// A tooltip pill. The scene controls visibility/lift through `style` (opacity + +// transform) and placement through `className`. +export function Tooltip( { + className, + style, + children, +}: { + className?: string; + style?: CSSProperties; + children: ReactNode; +} ) { + return ( + + { children } + + ); +} + +// Renders the first `count` characters of `text`, with a blinking caret while +// still streaming. Used for both the typed prompt and the streamed reply. +export function StreamingText( { + text, + count, + className, + caretClassName, +}: { + text: string; + count: number; + className?: string; + caretClassName?: string; +} ) { + return ( + + { text.slice( 0, count ) } + { count < text.length ? : null } + + ); +} diff --git a/apps/ui/src/components/onboarding-guide/illustrations/sites.tsx b/apps/ui/src/components/onboarding-guide/illustrations/sites.tsx new file mode 100644 index 0000000000..15367e3941 --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/illustrations/sites.tsx @@ -0,0 +1,170 @@ +import { __ } from '@wordpress/i18n'; +import { privateApis } from '@wordpress/theme'; +import { clsx } from 'clsx'; +import { useColorScheme } from '@/hooks/use-color-scheme'; +import { unlock } from '@/lock-unlock'; +import { easings, envelope, sample, useTimeline, type Keyframe } from './choreography'; +import { Cursor, Tooltip } from './primitives'; +import styles from './style.module.css'; +import type { CSSProperties } from 'react'; + +const { ThemeProvider } = unlock( privateApis ); + +// The sidebar sits on the dark window chrome in both color schemes, so this +// scene mirrors the sidebar-layout: a dark chrome background and a nested dark +// theme scope so the row's wpds tokens resolve against the dark ramp. +const CHROME_BG_LIGHT = '#1e1e1e'; +const CHROME_BG_DARK = '#161616'; + +const LOOP = 11000; + +// The pointer's path (offsets from the row centre): drift in → hover the +// overview button → move to the status button → press → hold while it starts → +// leave. Per-segment easing gives it a human cadence. +const CURSOR_PATH: Keyframe[] = [ + { at: 0, x: 168, y: 96, scale: 1, opacity: 0, ease: easings.easeOut }, + { at: 1100, x: 82, y: 8, scale: 1, opacity: 1, ease: easings.linear }, + { at: 3080, x: 82, y: 8, scale: 1, opacity: 1, ease: easings.easeInOut }, + { at: 4070, x: 108, y: 8, scale: 1, opacity: 1, ease: easings.easeIn }, + { at: 4840, x: 108, y: 8, scale: 1, opacity: 1, ease: easings.easeIn }, + { at: 5005, x: 108, y: 9, scale: 0.86, opacity: 1, ease: easings.easeOut }, + { at: 5170, x: 108, y: 8, scale: 1, opacity: 1, ease: easings.linear }, + { at: 8470, x: 108, y: 8, scale: 1, opacity: 1, ease: easings.easeIn }, + { at: 9460, x: 170, y: 98, scale: 1, opacity: 0 }, + { at: LOOP, x: 170, y: 98, scale: 1, opacity: 0 }, +]; + +// Play triangle: shown while stopped, gone once the site starts, back in during +// the closing pause so the loop resets cleanly. +function playOpacity( t: number ): number { + if ( t < 4840 ) { + return 1; + } + if ( t < 5170 ) { + return 1 - ( t - 4840 ) / 330; + } + if ( t < 10300 ) { + return 0; + } + if ( t < 10800 ) { + return ( t - 10300 ) / 500; + } + return 1; +} + +// Status dot: fades in amber (starting, with a gentle pulse), switches to green +// (running), then fades out for the reset. +function statusDot( t: number ): { opacity: number; color: string } { + let opacity = envelope( t, 4840, 330, 9900, 550 ); + if ( t > 5170 && t < 6820 ) { + const phase = ( ( t - 5170 ) / 900 ) % 1; + opacity *= 0.4 + 0.6 * Math.abs( 1 - 2 * phase ); + } + const color = + t < 7040 ? 'var(--studio-color-status-transitioning)' : 'var(--studio-color-status-running)'; + return { opacity, color }; +} + +function tooltipStyle( opacity: number ): CSSProperties { + return { opacity, transform: `translateX(-50%) translateY(${ ( 1 - opacity ) * 3 }px)` }; +} + +function hoverStyle( opacity: number ): CSSProperties { + return { backgroundColor: `rgba(255, 255, 255, ${ ( 0.09 * opacity ).toFixed( 3 ) })` }; +} + +function SettingsGlyph() { + return ( + + ); +} + +// Page 1 — the sidebar. A single site row plays out what the copy points at: a +// cursor drifts in, hovers the site-overview button (tooltip), moves to the +// status button and clicks it to start the site — stopped (play) → starting +// (amber) → running (green) — then leaves and the loop repeats. +export function SitesIllustration() { + const colorScheme = useColorScheme(); + const chromeBg = colorScheme === 'dark' ? CHROME_BG_DARK : CHROME_BG_LIGHT; + const { t } = useTimeline( { duration: LOOP, loop: true } ); + + const cursor = sample( t, CURSOR_PATH ); + const dot = statusDot( t ); + const overviewTip = envelope( t, 770, 440, 2970, 440 ); + const stoppedTip = envelope( t, 3740, 440, 4840, 330 ); + const startingTip = envelope( t, 4840, 330, 6820, 330 ); + const runningTip = envelope( t, 6820, 330, 8580, 440 ); + + return ( + + + + ); +} diff --git a/apps/ui/src/components/onboarding-guide/illustrations/style.module.css b/apps/ui/src/components/onboarding-guide/illustrations/style.module.css new file mode 100644 index 0000000000..dc82ab664f --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/illustrations/style.module.css @@ -0,0 +1,421 @@ +/* ---- Shared stage ---- + Motion for the scenes is driven in JS by the choreography timeline; this file + only holds static styling and two tiny local effects (caret blink, tool + shimmer). */ + +/* The full-bleed header slot. A fixed aspect ratio gives the popup header a + stable height that can't collapse while the dialog animates in; overflow is + visible so tooltips can extend past the edge. */ +.stage { + position: relative; + display: grid; + place-items: center; + width: 100%; + aspect-ratio: 320 / 150; + flex-shrink: 0; + overflow: visible; + border-bottom: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak); +} + +/* Fallback for ids without a built scene yet. */ +.placeholder { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 8%, transparent); +} + +/* ---- Sites scene ---- */ + +.sitesScene { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: var(--sites-chrome-bg); +} + +/* A stack of rows so the focus row reads as one entry in a real sidebar list. + No container mask — a mask would clip the focus row's tooltips at the list's + edge; the neighbours recede via per-row opacity instead. */ +.list { + display: flex; + flex-direction: column; + gap: 1px; + width: 264px; +} + +/* Quiet, non-interactive neighbours; the outer ones fade further back. */ +.rowGhost { + box-sizing: border-box; + display: flex; + align-items: center; + height: 34px; + padding: 0 var(--wpds-dimension-padding-sm) 0 var(--wpds-dimension-padding-md); + border-radius: 6px; + opacity: 0.7; +} + +.rowGhostFar { + opacity: 0.4; +} + +.rowGhostName { + overflow: hidden; + font-size: var(--wpds-typography-font-size-md); + line-height: 1.2; + color: var(--wpds-color-fg-content-neutral-weak); + white-space: nowrap; + text-overflow: ellipsis; +} + +.row { + position: relative; + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--wpds-dimension-padding-xs); + width: 100%; + height: 34px; + padding: 0 var(--wpds-dimension-padding-sm) 0 var(--wpds-dimension-padding-md); + border-radius: 6px; + background: var(--wpds-color-bg-interactive-neutral-weak-active); +} + +.rowName { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: var(--wpds-typography-font-size-md); + line-height: 1.2; + color: var(--wpds-color-fg-content-neutral); + white-space: nowrap; + text-overflow: ellipsis; +} + +.rowActions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +/* The scene tints the background (hover) inline via the timeline. */ +.btn { + position: relative; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 4px; + color: var(--wpds-color-fg-content-neutral); +} + +.btnGlyph { + width: 18px; + height: 18px; + fill: currentColor; +} + +/* The status glyphs stack in the button's single grid cell so they can + crossfade between states (play → starting dot → running dot). */ +.statusPlay, +.statusDot { + grid-area: 1 / 1; +} + +.statusPlay { + width: 9px; + height: 9px; + fill: var(--wpds-color-fg-content-neutral); +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +/* Tooltip pill. Visibility/lift come from the scene inline; positioning and + look live here. Centered over the button. */ +.tooltip { + position: absolute; + bottom: calc(100% + 7px); + left: 50%; + padding: 3px 8px; + border-radius: 4px; + background: #fff; + color: #1e1e1e; + font-size: 11px; + font-weight: var(--wpds-typography-font-weight-medium); + line-height: 1.4; + white-space: nowrap; + box-shadow: 0 2px 8px rgb(0 0 0 / 24%); + pointer-events: none; +} + +.cursor { + position: absolute; + top: 50%; + left: 50%; + transform-origin: 4px 3px; + filter: drop-shadow(0 2px 3px rgb(0 0 0 / 30%)); + will-change: transform, opacity; +} + +/* ---- Chat scene ---- + Theme-adaptive (the chat pane follows light/dark). */ + +.chatScene { + position: absolute; + inset: 0; + box-sizing: border-box; + overflow: hidden; + background: var(--wpds-color-bg-surface-neutral-weak); +} + +/* Above the resting composer, with room up top so the first bubble clears the + modal's Skip button. */ +.chatConversation { + position: absolute; + top: 46px; + right: 26px; + bottom: 20px; + left: 26px; + display: flex; + flex-direction: column; + gap: 16px; + overflow: hidden; +} + +.userBubble { + align-self: flex-end; + max-width: 82%; + padding: 5px 11px; + border-radius: 14px 14px 6px 14px; + background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand, #2563eb) 12%, transparent); + color: var(--wpds-color-fg-content-neutral); + font-size: var(--wpds-typography-font-size-sm); + line-height: 1.4; + transform-origin: bottom right; +} + +.assistantText { + color: var(--wpds-color-fg-content-neutral); + font-size: var(--wpds-typography-font-size-sm); + line-height: 1.45; +} + +/* Blinking cursor at the end of the streaming reply. */ +.streamCaret { + display: inline-block; + width: 6px; + height: 13px; + margin-left: 2px; + vertical-align: -2px; + background: var(--wpds-color-fg-content-neutral-weak); + animation: chatCaretBlink 1s steps(1) infinite; +} + +.toolChip { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); +} + +.toolIcon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +/* A light band sweeps across the label to signal the tool is working. */ +.toolShimmer { + background: linear-gradient( + 100deg, + var(--wpds-color-fg-content-neutral-weak) 35%, + var(--wpds-color-fg-content-neutral) 50%, + var(--wpds-color-fg-content-neutral-weak) 65% + ); + background-size: 220% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + animation: toolShimmer 1.6s linear infinite; +} + +@keyframes toolShimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +/* Subtle text control at the modal's top-left, shown once the playback ends. + Opacity/pointer-events are set inline by the scene. */ +.replayButton { + position: absolute; + top: 10px; + left: 14px; + z-index: 2; + padding: 2px 4px; + border: 0; + background: transparent; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + font-weight: var(--wpds-typography-font-weight-medium); + cursor: var(--wpds-cursor-control, pointer); + transition: color 120ms ease; +} + +.replayButton:hover { + color: var(--wpds-color-fg-content-neutral); +} + +.composer { + position: absolute; + right: 26px; + bottom: 12px; + left: 26px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px 7px; + border-radius: 8px 8px 20px 8px; + background: var(--wpds-color-bg-surface-neutral-strong); + outline: 1.5px solid var(--wpds-color-fg-interactive-brand, #2563eb); + will-change: transform; +} + +.composerInput { + position: relative; + min-height: 17px; +} + +.composerPlaceholder { + position: absolute; + inset: 0; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-sm); + line-height: 17px; + white-space: nowrap; +} + +.composerTyped { + position: absolute; + inset: 0; + display: inline-flex; + align-items: center; + overflow: hidden; + color: var(--wpds-color-fg-content-neutral); + font-size: var(--wpds-typography-font-size-sm); + line-height: 17px; + white-space: nowrap; +} + +.composerCaret { + flex-shrink: 0; + width: 1.5px; + height: 13px; + margin-left: 1px; + background: var(--wpds-color-fg-interactive-brand, #2563eb); + animation: chatCaretBlink 1s steps(1) infinite; +} + +.composerBar { + display: flex; + align-items: center; + justify-content: space-between; +} + +.composerButtons { + display: flex; + align-items: center; + gap: 6px; +} + +/* Black stop button (fg-content-neutral) with a light square, matching the + real composer's busy state; shown alongside the send button. */ +.stopButton { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 999px; + background: var(--wpds-color-fg-content-neutral); + color: var(--wpds-color-bg-surface-neutral-strong); +} + +.stopGlyph { + width: 8px; + height: 8px; + border-radius: 1px; + background: currentColor; +} + +.plusButton { + display: grid; + place-items: center; + width: 18px; + height: 18px; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.plusButton svg { + width: 16px; + height: 16px; +} + +.sendButton { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 999px; + background: color-mix(in srgb, var(--wpds-color-fg-content-neutral) 14%, transparent); + color: var(--wpds-color-fg-content-neutral-weak); + transition: background 150ms ease, color 150ms ease; +} + +.sendButton svg { + width: 15px; + height: 15px; +} + +.sendButtonActive { + background: var(--wpds-color-fg-interactive-brand, #2563eb); + color: #fff; +} + +@keyframes chatCaretBlink { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} + +/* The choreography timeline already rests on a static frame under reduced + motion; only the small looping CSS effects need silencing. */ +@media (prefers-reduced-motion: reduce) { + .streamCaret, + .composerCaret { + animation: none; + } + + .toolShimmer { + animation: none; + background: none; + color: var(--wpds-color-fg-content-neutral-weak); + -webkit-text-fill-color: var(--wpds-color-fg-content-neutral-weak); + } +} diff --git a/apps/ui/src/components/onboarding-guide/index.tsx b/apps/ui/src/components/onboarding-guide/index.tsx index 66189938eb..7a57a4dd1c 100644 --- a/apps/ui/src/components/onboarding-guide/index.tsx +++ b/apps/ui/src/components/onboarding-guide/index.tsx @@ -2,7 +2,7 @@ import { __ } from '@wordpress/i18n'; import { Button, Dialog } from '@wordpress/ui'; import { clsx } from 'clsx'; import { useState } from 'react'; -import { OrientationIllustration } from './illustrations'; +import { OrientationIllustration, isDarkOrientationIllustration } from './illustrations'; import styles from './style.module.css'; import type { GuideDefinition } from '@/data/onboarding/orientation-guide'; @@ -49,7 +49,13 @@ export function OnboardingGuide( { guide, onComplete, onDismiss }: OnboardingGui > - + { page.title() } diff --git a/apps/ui/src/components/onboarding-guide/style.module.css b/apps/ui/src/components/onboarding-guide/style.module.css index 86b3a1c2dc..3b27201891 100644 --- a/apps/ui/src/components/onboarding-guide/style.module.css +++ b/apps/ui/src/components/onboarding-guide/style.module.css @@ -10,20 +10,6 @@ max-width: calc(100vw - 32px); } -/* Full-bleed illustration slot (placeholder until real art lands). A fixed - aspect ratio gives the header a stable height that doesn't depend on - content, so it can't collapse while the dialog animates in. */ -.illustration { - display: block; - width: 100%; - aspect-ratio: 320 / 150; - /* The popup is a flex column; never let the header art get squeezed when - the viewport height constrains the popup. */ - flex-shrink: 0; - background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 8%, transparent); - border-bottom: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak); -} - .close { position: absolute; top: var(--wpds-dimension-padding-sm); @@ -31,6 +17,14 @@ z-index: 1; } +/* Over a dark full-bleed illustration the default (dark) icon vanishes; force a + light icon so Skip stays legible. */ +.closeOnDark, +.closeOnDark svg { + color: #fff; + fill: currentColor; +} + /* The modal backdrop covers the window's usual drag region, so nothing is movable while the tour is open. The popup itself can't be a drag handle — it's centered with a CSS transform, and Chromium ignores -webkit-app-region