diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 96401ee5d1..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, @@ -240,6 +241,7 @@ export { getColorScheme, getGlobalAgentInstructions, getInstalledAppsAndTerminals, + getOnboardingHints, getQuitSitesBehavior, getUserEditor, getUserLocale, @@ -250,6 +252,7 @@ export { saveAnalyticsEnabled, saveColorScheme, saveGlobalAgentInstructions, + saveOnboardingHints, saveQuitSitesBehavior, saveUserEditor, saveUserLocale, @@ -1531,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/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 2c17fa75ea..9793542d64 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -46,6 +46,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 d3faf195d9..153ee585f5 100644 --- a/apps/studio/src/menu.ts +++ b/apps/studio/src/menu.ts @@ -424,6 +424,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 7b8ff9aa8c..f18e7b2b5b 100644 --- a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts @@ -13,6 +13,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, @@ -195,6 +196,42 @@ export async function getWapuuScore(): Promise< number | undefined > { return userData.wapuuScore; } +// 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 ?? {}; +} + +async function persistOnboardingHints( partial: Partial< OnboardingHintsState > ): Promise< void > { + if ( ! partial || typeof partial !== 'object' ) { + return; + } + await lockAppdata(); + try { + const userData = await loadUserData(); + const merged: OnboardingHintsState = { ...( userData.onboardingHints ?? {} ), ...partial }; + await saveUserData( { ...userData, onboardingHints: merged } ); + } finally { + await unlockAppdata(); + } +} + +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/preload.ts b/apps/studio/src/preload.ts index 5bab9cce7e..2ccbb330e3 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -179,6 +179,8 @@ const api: IpcApi = { getAgenticFeaturesEnabled: () => ipcRendererInvoke( 'getAgenticFeaturesEnabled' ), 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 a5acd98b7e..d576f5d91d 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -57,6 +57,8 @@ export interface UserData { // Whether chat/agent features are offered inside the new UI. Distinct from // `betaFeatures.enableAgenticUi`, which picks the renderer (new vs classic). agenticFeaturesEnabled?: boolean; + /** Agentic UI onboarding state (orientation tour, getting-started checklist). Opaque blob owned by the renderer. */ + onboardingHints?: OnboardingHintsState; } export interface PromptWindowsSpeedUpResult { @@ -65,6 +67,15 @@ 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; + migratedFromClassic?: 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 d7b1d130d3..8e56e7128c 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/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 new file mode 100644 index 0000000000..7a57a4dd1c --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/index.tsx @@ -0,0 +1,90 @@ +import { __ } from '@wordpress/i18n'; +import { Button, Dialog } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { useState } from 'react'; +import { OrientationIllustration, isDarkOrientationIllustration } 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..3b27201891 --- /dev/null +++ b/apps/ui/src/components/onboarding-guide/style.module.css @@ -0,0 +1,116 @@ +/* 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); +} + +.close { + position: absolute; + top: var(--wpds-dimension-padding-sm); + inset-inline-end: var(--wpds-dimension-padding-sm); + 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 + 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 { + 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: start; +} + +.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; + /* 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; + 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/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 976b3de374..d6cd8aa699 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 { readWapuuScore, writeWapuuScore } from '../wapuu-score-storage'; @@ -463,6 +464,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 53ec379208..f502db6d27 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -920,6 +920,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 f9f29b7f5b..4c260bc7e9 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, getSignUpUrl } 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'; @@ -846,6 +847,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 d787848ece..356915c981 100644 --- a/apps/ui/src/data/core/index.ts +++ b/apps/ui/src/data/core/index.ts @@ -13,6 +13,7 @@ export type { InstalledApps, LocalMediaFile, LoadedAiSession, + OnboardingHintsState, ProposedSitePath, PullSiteProgress, QuitSitesBehavior, diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 9d4ee6eff3..1a41154bc2 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -446,6 +446,16 @@ export interface Connector { // Switches back to the legacy (classic) Studio UI. disableAgenticUi(): Promise< void >; + // 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 >; + + // 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 >; @@ -457,6 +467,19 @@ export interface AppUpdateStatus { version: string | null; } +// Persisted first-run onboarding state for the workbench. Separate from the +// pre-workbench welcome flag (getOnboardingCompleted). +export interface OnboardingHintsState { + // Version of the orientation guide the user finished or explicitly skipped. + 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 { 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..5cd8df5b01 --- /dev/null +++ b/apps/ui/src/data/onboarding/orientation-guide.ts @@ -0,0 +1,117 @@ +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 = 2; + +// 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'; + +export interface GuidePage { + illustration: OrientationIllustrationId; + title: () => string; + description: () => string; + // The advance button's label. + action: () => string; +} + +export interface GuideDefinition { + pages: GuidePage[]; +} + +// 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: () => + /* 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.' + ), + action: () => __( 'Next' ), + }; +} + +// 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 our AI agent, Studio Code, builds it.' + ), + 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' ), + }; +} + +// 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: () => + /* 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.' + ), + action: () => __( 'Let’s go' ), + }; + } + return { + 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.' + ), + action: () => __( 'Let’s go' ), + }; +} + +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 new file mode 100644 index 0000000000..c515049dfd --- /dev/null +++ b/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts @@ -0,0 +1,82 @@ +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: { chatEnabled: true, isReady: true }, + hints: {} as OnboardingHintsState, + guideOpen: false, + alreadyStarted: false, +}; + +describe( 'deriveOrientationAutostart', () => { + it( 'opens the chat guide for a fresh install when everything is ready', () => { + expect( deriveOrientationAutostart( base ) ).toEqual( { migrating: false, chatEnabled: true } ); + } ); + + it( 'marks the non-chat variant when chat is unavailable (signed out, offline, or opted out)', () => { + expect( + 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', () => { + 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: { chatEnabled: 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 }, + } ) + ).toEqual( { migrating: false, chatEnabled: true } ); + } ); + + 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..1a3eb6dc4a --- /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: { chatEnabled: 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 { migrating: hints.migratedFromClassic ?? false, chatEnabled: agentic.chatEnabled }; +} + +// 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: { chatEnabled: agentic.chatEnabled, 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.chatEnabled, + 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..1212f92ec5 --- /dev/null +++ b/apps/ui/src/data/onboarding/use-orientation-replay.ts @@ -0,0 +1,45 @@ +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 { useOnboardingHints, 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 { data: hints } = useOnboardingHints(); + const setHints = useSetOnboardingHints(); + const { openGuide } = useOnboardingGuide(); + + // 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( () => { + variantRef.current = { + migrating: hints?.migratedFromClassic ?? false, + chatEnabled: agentic.chatEnabled, + }; + }, [ agentic.chatEnabled, hints?.migratedFromClassic ] ); + + useEffect( () => { + return connector.onShowGettingStarted( () => { + openGuide( variantRef.current, { + 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..130e605cf3 --- /dev/null +++ b/apps/ui/src/data/queries/use-onboarding-hints.ts @@ -0,0 +1,49 @@ +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, + } ); +} + +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 ) => ( { ...( 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 0f52f93d26..30a1269ec9 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 { @@ -59,6 +61,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 );