Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add a first-run orientation guide, replayable from Help
  • Loading branch information
bcotrim committed Jul 24, 2026
commit 44a3e20de42364d8fa57b6df64cf035e8a7d8485
2 changes: 2 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export {
getColorScheme,
getGlobalAgentInstructions,
getInstalledAppsAndTerminals,
getOnboardingHints,
getQuitSitesBehavior,
getUserEditor,
getUserLocale,
Expand All @@ -240,6 +241,7 @@ export {
previewColorScheme,
saveColorScheme,
saveGlobalAgentInstructions,
saveOnboardingHints,
saveQuitSitesBehavior,
saveUserEditor,
saveUserLocale,
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions apps/studio/src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
? [
Expand Down
35 changes: 35 additions & 0 deletions apps/studio/src/modules/user-settings/lib/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() ) ?? '';
}
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ),
Expand Down
14 changes: 14 additions & 0 deletions apps/studio/src/storage/storage-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: {},
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/storage/user-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ type UserDataSafeKeys =
| 'cliAutoInstalled'
| 'cliUserUninstalled'
| 'wapuuScore'
| 'onboardingHints'
| 'lastNightlyUpdateCheck'
| 'nightlyPromptResult'
| 'agenticUiBannerDismissed';
Expand Down
5 changes: 4 additions & 1 deletion apps/ui/src/app/app-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,7 +43,9 @@ function ThemedApp( { children }: PropsWithChildren ) {
}, [ colorScheme ] );
return (
<ThemeProvider isRoot color={ themeColor } density="compact">
<Tooltip.Provider>{ children }</Tooltip.Provider>
<Tooltip.Provider>
<OnboardingGuideProvider>{ children }</OnboardingGuideProvider>
</Tooltip.Provider>
</ThemeProvider>
);
}
Expand Down
9 changes: 9 additions & 0 deletions apps/ui/src/components/onboarding-guide/illustrations.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className={ styles.illustration } data-illustration={ id } />;
}
84 changes: 84 additions & 0 deletions apps/ui/src/components/onboarding-guide/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog.Root
open
modal
// No dismissal by clicking the backdrop — only the close button, Esc,
// or finishing the guide.
disablePointerDismissal
onOpenChange={ ( open, details ) => {
if ( open ) {
return;
}
if ( details.reason === 'escape-key' || details.reason === 'close-press' ) {
onDismiss();
}
} }
>
<Dialog.Popup size="small" className={ styles.popup }>
<OrientationIllustration id={ page.illustration } />
<Dialog.CloseIcon label={ __( 'Skip' ) } className={ styles.close } />
<Dialog.Content className={ styles.content }>
<Dialog.Title className={ styles.title }>{ page.title() }</Dialog.Title>
<Dialog.Description className={ styles.description }>
{ page.description() }
</Dialog.Description>
</Dialog.Content>
<Dialog.Footer className={ styles.footer }>
<div className={ styles.footerStart }>
{ ! isFirst ? (
<Button variant="minimal" tone="neutral" onClick={ goBack }>
{ __( 'Back' ) }
</Button>
) : null }
</div>
<div className={ styles.pager } aria-hidden="true">
{ guide.pages.map( ( _, index ) => (
<span
key={ index }
className={ clsx( styles.dot, index === pageIndex && styles.dotActive ) }
/>
) ) }
</div>
<div className={ styles.footerEnd }>
<Button variant="solid" tone="brand" onClick={ goNext }>
{ page.action() }
</Button>
</div>
</Dialog.Footer>
</Dialog.Popup>
</Dialog.Root>
);
}
108 changes: 108 additions & 0 deletions apps/ui/src/components/onboarding-guide/style.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading