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 ) => (
+
- { ! 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;
+}