diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts index b860b7b0df..106013966b 100644 --- a/apps/studio/src/constants.ts +++ b/apps/studio/src/constants.ts @@ -66,8 +66,6 @@ export const IPC_VOID_HANDLERS = [ 'authenticate', ] as const; -// What's New -// Flip to `true` when shipping new modal content so users who haven't seen the -// current app version get the modal once. Keep at `false` otherwise — the modal -// will only auto-show for first-time users of Studio. -export const FORCE_SHOW_WHATS_NEW = false; +// What's New. Defined in @studio/common so the agentic UI reads the same switch; +// re-exported here so existing imports keep working. +export { FORCE_SHOW_WHATS_NEW } from '@studio/common/lib/whats-new'; diff --git a/apps/studio/src/modules/whats-new/components/whats-new-modal.tsx b/apps/studio/src/modules/whats-new/components/whats-new-modal.tsx index 6ec64d2070..bc6a634ff1 100644 --- a/apps/studio/src/modules/whats-new/components/whats-new-modal.tsx +++ b/apps/studio/src/modules/whats-new/components/whats-new-modal.tsx @@ -1,15 +1,15 @@ import interpolateComponents from '@automattic/interpolate-components'; +import cliIllustration from '@studio/common/assets/whats-new/cli.svg'; +import darkModeIllustration from '@studio/common/assets/whats-new/dark-mode.svg'; +import nativePhpIllustration from '@studio/common/assets/whats-new/native-php.svg'; +import phpMyAdminIllustration from '@studio/common/assets/whats-new/phpmyadmin.svg'; +import studioCodeIllustration from '@studio/common/assets/whats-new/studio-code.svg'; import { Guide } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { ReactNode } from 'react'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; -import cliIllustration from 'src/modules/whats-new/assets/cli-illustration.svg'; -import darkModeIllustration from 'src/modules/whats-new/assets/dark-mode-illustration.svg'; -import nativePhpIllustration from 'src/modules/whats-new/assets/native-php-illustration.svg'; -import phpMyAdminIllustration from 'src/modules/whats-new/assets/phpmyadmin-illustration.svg'; -import studioCodeIllustration from 'src/modules/whats-new/assets/studio-code-illustration.svg'; import { useI18nLocale } from 'src/stores'; interface WhatsNewPage { diff --git a/apps/ui/src/components/onboarding-guide/illustrations.tsx b/apps/ui/src/components/onboarding-guide/illustrations.tsx index 4d20f7faa6..9bbd24f093 100644 --- a/apps/ui/src/components/onboarding-guide/illustrations.tsx +++ b/apps/ui/src/components/onboarding-guide/illustrations.tsx @@ -1,9 +1,40 @@ +import cliIllustration from '@studio/common/assets/whats-new/cli.svg'; +import darkModeIllustration from '@studio/common/assets/whats-new/dark-mode.svg'; +import nativePhpIllustration from '@studio/common/assets/whats-new/native-php.svg'; +import phpMyAdminIllustration from '@studio/common/assets/whats-new/phpmyadmin.svg'; +import studioCodeIllustration from '@studio/common/assets/whats-new/studio-code.svg'; +import { __, sprintf } from '@wordpress/i18n'; import styles from './style.module.css'; -import type { OrientationIllustrationId } from '@/data/onboarding/orientation-guide'; +import type { GuideIllustrationId } from '@/data/onboarding/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
; +// Shared with the classic renderer's What's New modal. The orientation ids have +// no art yet and fall through to the tinted placeholder slot below. +const ILLUSTRATIONS: Partial< Record< GuideIllustrationId, string > > = { + 'studio-code': studioCodeIllustration, + 'native-php': nativePhpIllustration, + 'dark-mode': darkModeIllustration, + phpmyadmin: phpMyAdminIllustration, + cli: cliIllustration, +}; + +export function hasIllustration( id: GuideIllustrationId ): boolean { + return Boolean( ILLUSTRATIONS[ id ] ); +} + +export function GuideIllustration( { id, title }: { id: GuideIllustrationId; title: string } ) { + const source = ILLUSTRATIONS[ id ]; + if ( ! source ) { + return
; + } + return ( + { + ); } diff --git a/apps/ui/src/components/onboarding-guide/index.tsx b/apps/ui/src/components/onboarding-guide/index.tsx index 66189938eb..4f3350778c 100644 --- a/apps/ui/src/components/onboarding-guide/index.tsx +++ b/apps/ui/src/components/onboarding-guide/index.tsx @@ -1,10 +1,11 @@ import { __ } from '@wordpress/i18n'; -import { Button, Dialog } from '@wordpress/ui'; +import { Button, Dialog, Text, VisuallyHidden } from '@wordpress/ui'; import { clsx } from 'clsx'; -import { useState } from 'react'; -import { OrientationIllustration } from './illustrations'; +import { useRef, useState } from 'react'; +import { LearnMoreLink } from '@/components/learn-more'; +import { GuideIllustration, hasIllustration } from './illustrations'; import styles from './style.module.css'; -import type { GuideDefinition } from '@/data/onboarding/orientation-guide'; +import type { GuideDefinition } from '@/data/onboarding/guide'; interface OnboardingGuideProps { guide: GuideDefinition; @@ -21,6 +22,13 @@ export function OnboardingGuide( { guide, onComplete, onDismiss }: OnboardingGui const page = guide.pages[ pageIndex ]; const isFirst = pageIndex === 0; const isLast = pageIndex === guide.pages.length - 1; + // Reserve the link row for the whole guide, not per page, so paging through a + // guide where only some pages link out doesn't resize the modal. + const reservesLearnMore = guide.pages.some( ( { learnMore } ) => learnMore ); + // Without this the dialog focuses the first tabbable child, which on a page + // that links out is the "Learn more" link — so Enter would open the docs + // instead of advancing. + const advanceRef = useRef< HTMLButtonElement >( null ); const goNext = () => { if ( isLast ) { @@ -47,22 +55,64 @@ export function OnboardingGuide( { guide, onComplete, onDismiss }: OnboardingGui } } } > - - - + + + - { page.title() } - - { page.description() } - + { /* The dialog's accessible name and description follow the active page. + The visible copy is rendered separately below so that every page can + share one grid cell. */ } + }>{ page.title() } + }>{ page.description() } + { /* All pages occupy the same grid cell, so the box is as tall as the + longest one and advancing never resizes the modal. Rendering them + identically — rather than measuring the active page — is what keeps + the height honest once translations change the copy. */ } + { guide.pages.map( ( guidePage, index ) => ( +
+ + { guidePage.title() } + + + { guidePage.description() } + + { reservesLearnMore ? ( +
+ { guidePage.learnMore ? ( + + ) : null } +
+ ) : null } +
+ ) ) }
- { ! isFirst ? ( - - ) : null } + { /* Kept mounted on the first page and faded out instead of unmounted, + so it eases in alongside the pager rather than appearing from + nothing. `visibility` keeps it out of the tab order while hidden. */ } +
-
diff --git a/apps/ui/src/components/onboarding-guide/style.module.css b/apps/ui/src/components/onboarding-guide/style.module.css index 86b3a1c2dc..2989594f29 100644 --- a/apps/ui/src/components/onboarding-guide/style.module.css +++ b/apps/ui/src/components/onboarding-guide/style.module.css @@ -10,15 +10,13 @@ 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 +/* 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. */ + object-fit: cover; 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); @@ -31,6 +29,20 @@ z-index: 1; } +/* Over artwork the icon's default near-black vanishes on a dark illustration, + and no single colour works for art that is dark in one corner and light in + another. Deliberately not themed: the artwork doesn't change with the colour + scheme, so a theme-aware scrim would invert away from the thing it sits on. */ +.closeOverArt { + color: #fff; + background: rgb(0 0 0 / 55%); +} + +.closeOverArt:hover { + color: #fff; + background: rgb(0 0 0 / 70%); +} + /* 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 @@ -43,23 +55,35 @@ -webkit-app-region: drag; } +/* Every page shares one grid cell, so the box takes the height of the tallest + and stays there as the user advances. */ .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; + display: grid; box-sizing: border-box; padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-lg); text-align: start; } +.content > * { + grid-area: 1 / 1; +} + +.page { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-sm); +} + +/* Hidden rather than unmounted: an inactive page still has to take up its space + for the cell to size to the tallest. `visibility` also keeps it out of the + tab order and the accessibility tree. */ +.pageHidden { + visibility: hidden; +} + .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; } @@ -67,14 +91,19 @@ .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). */ +/* Present on every page of a guide that links out anywhere, so a page without a + link still reserves the row. */ +.learnMoreRow { + display: flex; + min-height: 20px; + align-items: center; +} + +/* The 1fr side columns keep the pager centered regardless of what flanks it. */ .footer { display: grid; grid-template-columns: 1fr auto 1fr; @@ -88,6 +117,21 @@ justify-self: start; } +/* Matches the pager's timing so the two settle together. */ +.back { + transition: opacity 200ms ease-in-out; +} + +.backHidden { + opacity: 0; + visibility: hidden; + pointer-events: none; + /* Hold the visibility flip until the fade finishes; coming back in uses + `.back`'s transition, which has no visibility delay, so it reappears at + once and fades up. */ + transition: opacity 200ms ease-in-out, visibility 0s 200ms; +} + .footerEnd { justify-self: end; } @@ -106,8 +150,17 @@ 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; + /* Mixed into the surface rather than into `transparent`: an alpha of its own + would animate 0.22 → 1 on the way to the opaque active colour, so the dot + faded in instead of changing colour and the shift landed late enough to + read as a pop. Opaque at both ends, only the hue moves. Same rendered + colour, and both tokens follow the theme. */ + background: color-mix( + in srgb, + var(--wpds-color-fg-content-neutral) 22%, + var(--wpds-color-bg-surface-neutral) + ); + transition: background 200ms ease-in-out, width 200ms ease-in-out; } .dotActive { @@ -116,7 +169,12 @@ } @media (prefers-reduced-motion: reduce) { - .dot { + .dot, + .back { + transition: none; + } + + .backHidden { 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 index 0fad352abe..5d4af3923f 100644 --- a/apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx +++ b/apps/ui/src/components/onboarding-guide/use-onboarding-guide.tsx @@ -1,7 +1,6 @@ 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 { GuideDefinition } from '@/data/onboarding/guide'; import type { ReactNode } from 'react'; export type GuideEndReason = 'completed' | 'dismissed'; @@ -12,7 +11,7 @@ interface OpenGuideOptions { interface OnboardingGuideApi { isOpen: boolean; - openGuide( variant: OrientationVariant, options?: OpenGuideOptions ): void; + openGuide( guide: GuideDefinition, options?: OpenGuideOptions ): void; close( reason: GuideEndReason ): void; } @@ -21,32 +20,32 @@ 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 [ guide, setGuide ] = useState< GuideDefinition | null >( null ); const onEndRef = useRef< ( ( reason: GuideEndReason ) => void ) | null >( null ); - const openGuide = useCallback( ( next: OrientationVariant, options?: OpenGuideOptions ) => { + const openGuide = useCallback( ( next: GuideDefinition, options?: OpenGuideOptions ) => { onEndRef.current = options?.onEnd ?? null; - setVariant( next ); + setGuide( next ); }, [] ); const close = useCallback( ( reason: GuideEndReason ) => { const callback = onEndRef.current; onEndRef.current = null; - setVariant( null ); + setGuide( null ); callback?.( reason ); }, [] ); const api = useMemo< OnboardingGuideApi >( - () => ( { isOpen: variant !== null, openGuide, close } ), - [ variant, openGuide, close ] + () => ( { isOpen: guide !== null, openGuide, close } ), + [ guide, openGuide, close ] ); return ( { children } - { variant ? ( + { guide ? ( close( 'completed' ) } onDismiss={ () => close( 'dismissed' ) } /> diff --git a/apps/ui/src/data/core/connectors/browser-whats-new.ts b/apps/ui/src/data/core/connectors/browser-whats-new.ts new file mode 100644 index 0000000000..1954760fdd --- /dev/null +++ b/apps/ui/src/data/core/connectors/browser-whats-new.ts @@ -0,0 +1,12 @@ +// App version the What's New announcements were last dismissed on. The desktop +// keeps this in appdata via IPC — shared with the classic renderer — but the +// browser connectors have no such store, so it lives in localStorage per origin. +const LAST_SEEN_VERSION_STORAGE_KEY = 'studio-whats-new-last-seen-version'; + +export function readLastSeenVersion(): string | undefined { + return window.localStorage.getItem( LAST_SEEN_VERSION_STORAGE_KEY ) ?? undefined; +} + +export function writeLastSeenVersion( version: string ): void { + window.localStorage.setItem( LAST_SEEN_VERSION_STORAGE_KEY, version ); +} diff --git a/apps/ui/src/data/core/connectors/hosted/index.ts b/apps/ui/src/data/core/connectors/hosted/index.ts index d6cd8aa699..908c1e10f7 100644 --- a/apps/ui/src/data/core/connectors/hosted/index.ts +++ b/apps/ui/src/data/core/connectors/hosted/index.ts @@ -2,6 +2,7 @@ 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 { readLastSeenVersion, writeLastSeenVersion } from '../browser-whats-new'; import { UnsupportedError } from '../unsupported-error'; import { readWapuuScore, writeWapuuScore } from '../wapuu-score-storage'; import type { @@ -474,6 +475,16 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ): // No application menu on the hosted surface. return () => {}; }, + onShowWhatsNew() { + // No application menu on the hosted surface. + return () => {}; + }, + async getLastSeenVersion() { + return readLastSeenVersion(); + }, + async saveLastSeenVersion( version ) { + writeLastSeenVersion( version ); + }, 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 f502db6d27..137cd42b64 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -934,6 +934,18 @@ export function createIpcConnector(): Connector { return ipcListener.subscribe( 'show-getting-started', () => listener() ); }, + onShowWhatsNew( listener ) { + return ipcListener.subscribe( 'show-whats-new', () => listener() ); + }, + + async getLastSeenVersion() { + return ipcApi.getLastSeenVersion(); + }, + + async saveLastSeenVersion( version ) { + await ipcApi.saveLastSeenVersion( version ); + }, + 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 4c260bc7e9..031b4e5ef6 100644 --- a/apps/ui/src/data/core/connectors/local/index.ts +++ b/apps/ui/src/data/core/connectors/local/index.ts @@ -3,6 +3,7 @@ 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 { readLastSeenVersion, writeLastSeenVersion } from '../browser-whats-new'; import { buildPublishCheckoutUrl } from '../publish-checkout-url'; import { UnsupportedError } from '../unsupported-error'; import { readWapuuScore, writeWapuuScore } from '../wapuu-score-storage'; @@ -857,6 +858,16 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C // No application menu in a browser tab. return () => {}; }, + onShowWhatsNew() { + // No application menu in a browser tab. + return () => {}; + }, + async getLastSeenVersion() { + return readLastSeenVersion(); + }, + async saveLastSeenVersion( version ) { + writeLastSeenVersion( version ); + }, async getAppUpdateStatus() { return { readyToInstall: false, version: null }; }, diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 1a41154bc2..1eb371f7d2 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -456,6 +456,16 @@ export interface Connector { // (desktop only). No-ops where there's no OS menu. onShowGettingStarted( listener: () => void ): () => void; + // Fires when the user picks Help ▸ What's New in the application menu + // (desktop only). No-ops where there's no OS menu. + onShowWhatsNew( listener: () => void ): () => void; + + // App version the user last dismissed the What's New announcements on. The + // same value the classic renderer reads, so the two UIs never show the same + // announcements twice. + getLastSeenVersion(): Promise< string | undefined >; + saveLastSeenVersion( version: string ): Promise< void >; + // Auto-updater status. getAppUpdateStatus(): Promise< AppUpdateStatus >; installAppUpdate(): Promise< void >; @@ -519,6 +529,8 @@ export interface UserPreferences { export interface AppGlobals { platform: string; isWindowsStore: boolean; + // Desktop only — the browser connectors have no app to report a version for. + appVersion?: string; } // Subset of UserPreferences that callers can actually mutate. `locale` is diff --git a/apps/ui/src/data/onboarding/guide.ts b/apps/ui/src/data/onboarding/guide.ts new file mode 100644 index 0000000000..e9b8afd674 --- /dev/null +++ b/apps/ui/src/data/onboarding/guide.ts @@ -0,0 +1,33 @@ +import type { DocsLinkKey } from '@/lib/docs-links'; + +// The shape both paged guides share: the first-run orientation tour +// (orientation-guide.ts) and the per-release announcements (whats-new.ts). +// Rendered by components/onboarding-guide. + +export type GuideIllustrationId = + // Orientation. + | 'sites' + | 'chat' + | 'preview' + | 'overview' + // What's New. + | 'studio-code' + | 'native-php' + | 'dark-mode' + | 'phpmyadmin' + | 'cli'; + +export interface GuidePage { + illustration: GuideIllustrationId; + // Thunks, not strings: the pages are built once per open but the locale can + // change under them, so translation happens at render time. + title: () => string; + description: () => string; + // The advance button's label. + action: () => string; + learnMore?: DocsLinkKey; +} + +export interface GuideDefinition { + pages: GuidePage[]; +} diff --git a/apps/ui/src/data/onboarding/orientation-guide.ts b/apps/ui/src/data/onboarding/orientation-guide.ts index 5cd8df5b01..b9db0f4862 100644 --- a/apps/ui/src/data/onboarding/orientation-guide.ts +++ b/apps/ui/src/data/onboarding/orientation-guide.ts @@ -1,4 +1,5 @@ import { __ } from '@wordpress/i18n'; +import type { GuideDefinition, GuidePage } from './guide'; // Bump to re-show the orientation guide to everyone who saw the previous // version (compared against OnboardingHintsState.tourCompletedVersion / @@ -15,20 +16,6 @@ export interface OrientationVariant { 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 { 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 c515049dfd..239eeca3b5 100644 --- a/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts +++ b/apps/ui/src/data/onboarding/use-orientation-autostart.test.ts @@ -4,7 +4,6 @@ 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, @@ -29,11 +28,6 @@ describe( 'deriveOrientationAutostart', () => { ).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(); } ); diff --git a/apps/ui/src/data/onboarding/use-orientation-autostart.ts b/apps/ui/src/data/onboarding/use-orientation-autostart.ts index 1a3eb6dc4a..7387186645 100644 --- a/apps/ui/src/data/onboarding/use-orientation-autostart.ts +++ b/apps/ui/src/data/onboarding/use-orientation-autostart.ts @@ -1,18 +1,13 @@ 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 { useOnboardingHints, useSetOnboardingHints } from '@/data/queries/use-onboarding-hints'; import { useSites } from '@/data/queries/use-sites'; -import { ORIENTATION_GUIDE_VERSION } from './orientation-guide'; +import { getOrientationGuide, 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; @@ -22,12 +17,11 @@ interface AutostartInputs { /** * 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. + * Returns null unless the user 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, @@ -37,7 +31,13 @@ export function deriveOrientationAutostart( { if ( alreadyStarted || guideOpen ) { return null; } - if ( onboardingCompleted !== true || siteCount < 1 || ! agentic.isReady ) { + // Having a site is the real "past the NUX" signal, and the only one that works + // for existing users. `onboardingCompleted` is written solely by the welcome + // screen (route-welcome), which route-index only routes to when there are no + // sites — so anyone who had sites before that flag existed keeps it `false` + // forever and would never see the guide. The guides mount in the dashboard + // layout, so they can't render during the welcome flow anyway. + if ( siteCount < 1 || ! agentic.isReady ) { return null; } if ( hints === undefined ) { @@ -67,14 +67,11 @@ export function useOrientationAutostart(): void { 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, @@ -88,7 +85,7 @@ export function useOrientationAutostart(): void { startedRef.current = true; startTimerRef.current = setTimeout( () => { startTimerRef.current = null; - openGuide( variant, { + openGuide( getOrientationGuide( variant ), { onEnd: ( reason ) => { if ( reason === 'completed' ) { setHints.mutate( { tourCompletedVersion: ORIENTATION_GUIDE_VERSION } ); @@ -101,16 +98,7 @@ export function useOrientationAutostart(): void { // 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, - ] ); + }, [ sites?.length, agentic.chatEnabled, agentic.isReady, hints, isOpen, openGuide, setHints ] ); useEffect( () => { return () => { diff --git a/apps/ui/src/data/onboarding/use-orientation-replay.ts b/apps/ui/src/data/onboarding/use-orientation-replay.ts index 1212f92ec5..2bbddbb2a5 100644 --- a/apps/ui/src/data/onboarding/use-orientation-replay.ts +++ b/apps/ui/src/data/onboarding/use-orientation-replay.ts @@ -3,7 +3,7 @@ import { useOnboardingGuide } from '@/components/onboarding-guide/use-onboarding 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'; +import { getOrientationGuide, ORIENTATION_GUIDE_VERSION } from './orientation-guide'; /** * Reopens the orientation guide when the user picks Help ▸ Getting Started in @@ -31,7 +31,7 @@ export function useOrientationReplay(): void { useEffect( () => { return connector.onShowGettingStarted( () => { - openGuide( variantRef.current, { + openGuide( getOrientationGuide( variantRef.current ), { onEnd: ( reason ) => { setHints.mutate( reason === 'completed' diff --git a/apps/ui/src/data/onboarding/use-whats-new-autostart.test.ts b/apps/ui/src/data/onboarding/use-whats-new-autostart.test.ts new file mode 100644 index 0000000000..b049777f1c --- /dev/null +++ b/apps/ui/src/data/onboarding/use-whats-new-autostart.test.ts @@ -0,0 +1,72 @@ +import { FORCE_SHOW_WHATS_NEW } from '@studio/common/lib/whats-new'; +import { describe, expect, it } from 'vitest'; +import { ORIENTATION_GUIDE_VERSION } from './orientation-guide'; +import { deriveWhatsNewAutostart } from './use-whats-new-autostart'; +import type { OnboardingHintsState } from '@/data/core'; + +// Everything ready, the user has been through orientation, and they have never +// dismissed the announcements — the state in which they actually open. +const base = { + siteCount: 1, + hints: { tourCompletedVersion: ORIENTATION_GUIDE_VERSION } as OnboardingHintsState, + lastSeenVersion: null, + currentVersion: '1.17.0', + guideOpen: false, + alreadyStarted: false, +}; + +describe( 'deriveWhatsNewAutostart', () => { + it( 'shows the announcements to someone who has never dismissed them', () => { + expect( deriveWhatsNewAutostart( base ) ).toBe( 'show' ); + } ); + + it( 'shows them when orientation was skipped rather than completed', () => { + expect( + deriveWhatsNewAutostart( { + ...base, + hints: { tourDismissedVersion: ORIENTATION_GUIDE_VERSION }, + } ) + ).toBe( 'show' ); + } ); + + it( 'banks the version without showing when orientation has not run yet', () => { + // A first-run user gets the orientation guide instead; nothing here is news + // to them, and two modals in a row is worse than none. + expect( deriveWhatsNewAutostart( { ...base, hints: {} } ) ).toBe( 'mark-seen' ); + } ); + + it( 'banks the version when orientation itself was bumped and is about to re-run', () => { + expect( + deriveWhatsNewAutostart( { + ...base, + hints: { tourCompletedVersion: ORIENTATION_GUIDE_VERSION - 1 }, + } ) + ).toBe( 'mark-seen' ); + } ); + + it( 'waits until there is at least one site', () => { + expect( deriveWhatsNewAutostart( { ...base, siteCount: 0 } ) ).toBeNull(); + } ); + + it( 'waits until hints and the stored version have loaded', () => { + expect( deriveWhatsNewAutostart( { ...base, hints: undefined } ) ).toBeNull(); + expect( deriveWhatsNewAutostart( { ...base, lastSeenVersion: undefined } ) ).toBeNull(); + } ); + + it( 'does not re-show once the announcements have been dismissed', () => { + expect( deriveWhatsNewAutostart( { ...base, lastSeenVersion: '1.17.0' } ) ).toBeNull(); + } ); + + it( 'follows FORCE_SHOW_WHATS_NEW on an app version the user has not seen', () => { + // The switch is the release lever: a version bump alone never re-shows the + // modal, so this tracks the constant rather than asserting one branch. + expect( deriveWhatsNewAutostart( { ...base, lastSeenVersion: '1.16.0' } ) ).toBe( + FORCE_SHOW_WHATS_NEW ? 'show' : null + ); + } ); + + it( 'does not open while a guide is already open or already started', () => { + expect( deriveWhatsNewAutostart( { ...base, guideOpen: true } ) ).toBeNull(); + expect( deriveWhatsNewAutostart( { ...base, alreadyStarted: true } ) ).toBeNull(); + } ); +} ); diff --git a/apps/ui/src/data/onboarding/use-whats-new-autostart.ts b/apps/ui/src/data/onboarding/use-whats-new-autostart.ts new file mode 100644 index 0000000000..bba85dc3d9 --- /dev/null +++ b/apps/ui/src/data/onboarding/use-whats-new-autostart.ts @@ -0,0 +1,129 @@ +import { hasUnseenWhatsNew } from '@studio/common/lib/whats-new'; +import { useEffect, useRef } from 'react'; +import { useOnboardingGuide } from '@/components/onboarding-guide/use-onboarding-guide'; +import { useOnboardingHints } from '@/data/queries/use-onboarding-hints'; +import { useSites } from '@/data/queries/use-sites'; +import { + useLastSeenVersion, + useSaveLastSeenVersion, + useWhatsNewVersion, +} from '@/data/queries/use-whats-new-seen'; +import { ORIENTATION_GUIDE_VERSION } from './orientation-guide'; +import { getWhatsNewGuide } from './whats-new'; +import type { OnboardingHintsState } from '@/data/core'; + +interface AutostartInputs { + siteCount: number; + hints: OnboardingHintsState | undefined; + lastSeenVersion: string | null | undefined; + currentVersion: string | undefined; + guideOpen: boolean; + alreadyStarted: boolean; +} + +export type WhatsNewAutostart = 'show' | 'mark-seen' | null; + +/** + * Pure decision: whether to auto-open the announcements. Returns null unless the + * user has at least one site, the stored marker has loaded, nothing is already + * showing, and this app session hasn't opened the guide yet. + */ +export function deriveWhatsNewAutostart( { + siteCount, + hints, + lastSeenVersion, + currentVersion, + guideOpen, + alreadyStarted, +}: AutostartInputs ): WhatsNewAutostart { + if ( alreadyStarted || guideOpen ) { + return null; + } + // Having a site is the real "past the NUX" signal — see the note in + // use-orientation-autostart.ts for why `onboardingCompleted` can't be used. + if ( siteCount < 1 ) { + return null; + } + if ( hints === undefined || lastSeenVersion === undefined ) { + return null; + } + if ( ! hasUnseenWhatsNew( lastSeenVersion ?? undefined, currentVersion ) ) { + return null; + } + // A first arrival in the workbench belongs to the orientation guide, and none + // of this is news to someone seeing the app for the first time — bank the + // version so it only interrupts on the next release with new content. This + // also keeps the two exclusive: orientation autostarts on exactly the state + // that returns 'mark-seen'. + const orientationSeen = + ( hints.tourCompletedVersion ?? 0 ) >= ORIENTATION_GUIDE_VERSION || + ( hints.tourDismissedVersion ?? 0 ) >= ORIENTATION_GUIDE_VERSION; + return orientationSeen ? 'show' : 'mark-seen'; +} + +// Matches the orientation guide's entrance delay, so the modal reads as arriving +// rather than as part of the initial paint. +const GUIDE_START_DELAY_MS = 500; + +/** + * Auto-opens the What's New guide when there are unseen announcements. Mounted + * in the dashboard layout. + */ +export function useWhatsNewAutostart(): void { + const { data: sites } = useSites(); + const { data: hints } = useOnboardingHints(); + const { data: lastSeenVersion } = useLastSeenVersion(); + const saveLastSeenVersion = useSaveLastSeenVersion(); + const { isOpen, openGuide } = useOnboardingGuide(); + + const currentVersion = useWhatsNewVersion(); + const startedRef = useRef( false ); + const startTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + + useEffect( () => { + const decision = deriveWhatsNewAutostart( { + siteCount: sites?.length ?? 0, + hints, + lastSeenVersion, + currentVersion, + guideOpen: isOpen, + alreadyStarted: startedRef.current, + } ); + if ( ! decision ) { + return; + } + startedRef.current = true; + if ( decision === 'mark-seen' ) { + saveLastSeenVersion.mutate( currentVersion ); + return; + } + startTimerRef.current = setTimeout( () => { + startTimerRef.current = null; + openGuide( getWhatsNewGuide(), { + // Skipping counts as seen: these are announcements, not a task, and + // re-interrupting someone who closed them is worse than them missing one. + onEnd: () => saveLastSeenVersion.mutate( currentVersion ), + } ); + }, 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. + }, [ + sites?.length, + hints, + lastSeenVersion, + currentVersion, + isOpen, + openGuide, + saveLastSeenVersion, + ] ); + + useEffect( () => { + return () => { + if ( startTimerRef.current ) { + clearTimeout( startTimerRef.current ); + startTimerRef.current = null; + } + }; + }, [] ); +} diff --git a/apps/ui/src/data/onboarding/use-whats-new-replay.ts b/apps/ui/src/data/onboarding/use-whats-new-replay.ts new file mode 100644 index 0000000000..09bd87bc19 --- /dev/null +++ b/apps/ui/src/data/onboarding/use-whats-new-replay.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react'; +import { useOnboardingGuide } from '@/components/onboarding-guide/use-onboarding-guide'; +import { useConnector } from '@/data/core'; +import { useSaveLastSeenVersion, useWhatsNewVersion } from '@/data/queries/use-whats-new-seen'; +import { getWhatsNewGuide } from './whats-new'; + +/** + * Reopens the What's New guide when the user picks Help ▸ What's New in the + * application menu. Replaying also records the current version, so a user who + * dug it out of the menu before it auto-opened isn't interrupted later. No-ops + * on surfaces without an OS menu — the connector subscription never fires. + */ +export function useWhatsNewReplay(): void { + const connector = useConnector(); + const currentVersion = useWhatsNewVersion(); + const saveLastSeenVersion = useSaveLastSeenVersion(); + const { openGuide } = useOnboardingGuide(); + + // The menu event fires outside React's data flow, so read the current version + // through a ref instead of resubscribing when app globals resolve. + const versionRef = useRef( currentVersion ); + useEffect( () => { + versionRef.current = currentVersion; + }, [ currentVersion ] ); + + useEffect( () => { + return connector.onShowWhatsNew( () => { + openGuide( getWhatsNewGuide(), { + onEnd: () => saveLastSeenVersion.mutate( versionRef.current ), + } ); + } ); + }, [ connector, openGuide, saveLastSeenVersion ] ); +} diff --git a/apps/ui/src/data/onboarding/whats-new.ts b/apps/ui/src/data/onboarding/whats-new.ts new file mode 100644 index 0000000000..2a51daa1b2 --- /dev/null +++ b/apps/ui/src/data/onboarding/whats-new.ts @@ -0,0 +1,61 @@ +import { __ } from '@wordpress/i18n'; +import type { GuideDefinition } from './guide'; + +// Keep these pages in step with the classic renderer's modal +// (apps/studio/src/modules/whats-new/components/whats-new-modal.tsx) — both are +// gated by the same FORCE_SHOW_WHATS_NEW switch in @studio/common. + +export function getWhatsNewGuide(): GuideDefinition { + return { + pages: [ + { + illustration: 'studio-code', + title: () => __( 'Studio Code helps you get it done' ), + description: () => + __( + 'From quick edits to new features, Studio Code helps you move faster by translating your ideas into working code.' + ), + action: () => __( 'Next' ), + learnMore: 'docsStudioCode', + }, + { + illustration: 'native-php', + title: () => __( 'Faster local sites with native PHP' ), + description: () => + __( + 'Studio now runs WordPress on native PHP by default — fewer abstractions, better performance. Switch between Native and Sandbox runtimes in your site settings.' + ), + action: () => __( 'Next' ), + learnMore: 'docsPhpRuntimes', + }, + { + illustration: 'dark-mode', + title: () => __( 'Dark mode is here' ), + description: () => + __( + 'Studio now supports light, dark, and system appearance modes. Head to Settings to choose your preferred look.' + ), + action: () => __( 'Next' ), + }, + { + illustration: 'phpmyadmin', + title: () => __( 'Manage your database with phpMyAdmin' ), + description: () => + __( + "Manage your site's database visually with phpMyAdmin, from the Database tab above the preview." + ), + action: () => __( 'Next' ), + }, + { + illustration: 'cli', + title: () => __( 'WP-CLI support and CLI site management' ), + description: () => + __( + 'Install the studio CLI to run WP-CLI commands from your terminal and create, start, stop, or update your sites.' + ), + action: () => __( 'Done' ), + learnMore: 'docsCli', + }, + ], + }; +} diff --git a/apps/ui/src/data/queries/use-whats-new-seen.ts b/apps/ui/src/data/queries/use-whats-new-seen.ts new file mode 100644 index 0000000000..cb3ab485cf --- /dev/null +++ b/apps/ui/src/data/queries/use-whats-new-seen.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useConnector } from '@/data/core'; +import { useAppGlobals } from '@/data/queries/use-app-globals'; + +// App version the announcements were last dismissed on — the same value the +// classic renderer reads and writes, so dismissing in one UI settles both. +export const LAST_SEEN_VERSION_QUERY_KEY = [ 'whats-new-last-seen-version' ] as const; + +// Browser targets (`studio ui`, hosted) have no app version to record, so they +// record a fixed stand-in. Known trade-off: because a later release stores the +// same value, a browser only ever sees the announcements once. Fixing that needs +// a marker that tracks the content rather than the app version, which would also +// replace FORCE_SHOW_WHATS_NEW — worth doing, but not in this PR. +const BROWSER_VERSION = 'browser'; + +// The version both the comparison and the write should use. +export function useWhatsNewVersion(): string { + const { data: appGlobals } = useAppGlobals(); + return appGlobals?.appVersion ?? BROWSER_VERSION; +} + +export function useLastSeenVersion() { + const connector = useConnector(); + return useQuery( { + queryKey: LAST_SEEN_VERSION_QUERY_KEY, + queryFn: async () => ( await connector.getLastSeenVersion() ) ?? null, + staleTime: Infinity, + meta: { persist: false }, + } ); +} + +export function useSaveLastSeenVersion() { + const connector = useConnector(); + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: ( version: string ) => connector.saveLastSeenVersion( version ), + // Optimistic so a second guard sees the write immediately; the connector + // is the source of truth on next launch. + onMutate: ( version ) => { + queryClient.setQueryData( LAST_SEEN_VERSION_QUERY_KEY, version ); + }, + } ); +} diff --git a/apps/ui/src/lib/docs-links.ts b/apps/ui/src/lib/docs-links.ts index eee053f8b7..f171ea4591 100644 --- a/apps/ui/src/lib/docs-links.ts +++ b/apps/ui/src/lib/docs-links.ts @@ -15,6 +15,9 @@ const DOCS_LINKS = { docsMcp: { en: 'https://developer.wordpress.com/docs/developer-tools/studio/mcp-on-studio/', }, + docsPhpRuntimes: { + en: 'https://developer.wordpress.com/docs/developer-tools/studio/php-runtimes/', + }, docsSites: { en: 'https://developer.wordpress.com/docs/developer-tools/studio/sites/', es: 'https://developer.wordpress.com/es/docs/herramientas-para-desarrolladores/studio/sitios/', @@ -29,6 +32,9 @@ const DOCS_LINKS = { en: 'https://developer.wordpress.com/docs/developer-tools/studio/', es: 'https://developer.wordpress.com/es/docs/herramientas-para-desarrolladores/studio/', }, + docsStudioCode: { + en: 'https://developer.wordpress.com/docs/developer-tools/studio/studio-code/', + }, docsSyncSupportedSites: { en: 'https://developer.wordpress.com/docs/developer-tools/studio/sync/#supported-sites', es: 'https://developer.wordpress.com/es/docs/herramientas-para-desarrolladores/studio/sync/#sitios-compatibles', 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 30a1269ec9..4e125646d4 100644 --- a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx +++ b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx @@ -9,6 +9,8 @@ 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 { useWhatsNewAutostart } from '@/data/onboarding/use-whats-new-autostart'; +import { useWhatsNewReplay } from '@/data/onboarding/use-whats-new-replay'; import { useSession, useSessionEffectiveEnvironment } from '@/data/queries/use-sessions'; import { useSites } from '@/data/queries/use-sites'; import { @@ -65,6 +67,9 @@ function DashboardLayoutContent() { // Getting Started replay it. useOrientationAutostart(); useOrientationReplay(); + // Same, for the per-release announcements behind Help ▸ What's New. + useWhatsNewAutostart(); + useWhatsNewReplay(); const preview = useSessionPreviewUI(); const onAnnotationsDone = useSessionPreviewAnnotationsHandler(); const sessionSite = findAiSessionOwnerSite( sites, sessionData?.summary ); diff --git a/apps/studio/src/modules/whats-new/assets/cli-illustration.svg b/packages/common/assets/whats-new/cli.svg similarity index 100% rename from apps/studio/src/modules/whats-new/assets/cli-illustration.svg rename to packages/common/assets/whats-new/cli.svg diff --git a/apps/studio/src/modules/whats-new/assets/dark-mode-illustration.svg b/packages/common/assets/whats-new/dark-mode.svg similarity index 100% rename from apps/studio/src/modules/whats-new/assets/dark-mode-illustration.svg rename to packages/common/assets/whats-new/dark-mode.svg diff --git a/apps/studio/src/modules/whats-new/assets/native-php-illustration.svg b/packages/common/assets/whats-new/native-php.svg similarity index 100% rename from apps/studio/src/modules/whats-new/assets/native-php-illustration.svg rename to packages/common/assets/whats-new/native-php.svg diff --git a/apps/studio/src/modules/whats-new/assets/phpmyadmin-illustration.svg b/packages/common/assets/whats-new/phpmyadmin.svg similarity index 100% rename from apps/studio/src/modules/whats-new/assets/phpmyadmin-illustration.svg rename to packages/common/assets/whats-new/phpmyadmin.svg diff --git a/apps/studio/src/modules/whats-new/assets/studio-code-illustration.svg b/packages/common/assets/whats-new/studio-code.svg similarity index 100% rename from apps/studio/src/modules/whats-new/assets/studio-code-illustration.svg rename to packages/common/assets/whats-new/studio-code.svg diff --git a/packages/common/lib/whats-new.ts b/packages/common/lib/whats-new.ts new file mode 100644 index 0000000000..42e522a4ea --- /dev/null +++ b/packages/common/lib/whats-new.ts @@ -0,0 +1,23 @@ +// Flip to `true` when shipping new modal content so users who haven't seen the +// current app version get the modal once. Keep at `false` otherwise — the modal +// will only auto-show for first-time users of Studio. +// +// Lives here rather than in either app because both renderers read it, and both +// compare it against the same stored `lastSeenVersion`: flipping it once shows +// the announcements in whichever UI the user is running, and dismissing them in +// one settles it for the other. +export const FORCE_SHOW_WHATS_NEW = false; + +// Whether the announcements should auto-show. Shared so the two UIs can't drift +// apart on the rule; see apps/studio/src/stores/app-version-api.ts for the +// classic renderer's selector and apps/ui/src/data/onboarding for the agentic +// one. +export function hasUnseenWhatsNew( + lastSeenVersion: string | undefined, + currentVersion: string | undefined +): boolean { + if ( ! lastSeenVersion ) { + return true; + } + return FORCE_SHOW_WHATS_NEW && lastSeenVersion !== currentVersion; +}