Skip to content
8 changes: 3 additions & 5 deletions apps/studio/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
10 changes: 5 additions & 5 deletions apps/studio/src/modules/whats-new/components/whats-new-modal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
43 changes: 37 additions & 6 deletions apps/ui/src/components/onboarding-guide/illustrations.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className={ styles.illustration } data-illustration={ id } />;
// 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 <div className={ styles.illustration } data-illustration={ id } />;
}
return (
<img
className={ styles.illustration }
src={ source }
alt={ sprintf(
/* translators: %s is the title of the guide page the illustration belongs to. */
__( 'Illustration for %s' ),
title
) }
/>
);
}
84 changes: 67 additions & 17 deletions apps/ui/src/components/onboarding-guide/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 ) {
Expand All @@ -47,22 +55,64 @@ export function OnboardingGuide( { guide, onComplete, onDismiss }: OnboardingGui
}
} }
>
<Dialog.Popup size="small" className={ styles.popup } data-orientation-guide>
<OrientationIllustration id={ page.illustration } />
<Dialog.CloseIcon label={ __( 'Skip' ) } className={ styles.close } />
<Dialog.Popup
size="small"
className={ styles.popup }
initialFocus={ advanceRef }
data-orientation-guide
>
<GuideIllustration id={ page.illustration } title={ page.title() } />
<Dialog.CloseIcon
label={ __( 'Skip' ) }
className={ clsx(
styles.close,
hasIllustration( page.illustration ) && styles.closeOverArt
) }
/>
<Dialog.Content className={ styles.content }>
<Dialog.Title className={ styles.title }>{ page.title() }</Dialog.Title>
<Dialog.Description className={ styles.description }>
{ page.description() }
</Dialog.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. */ }
<VisuallyHidden render={ <Dialog.Title /> }>{ page.title() }</VisuallyHidden>
<VisuallyHidden render={ <Dialog.Description /> }>{ page.description() }</VisuallyHidden>
{ /* 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 ) => (
<div
key={ index }
className={ clsx( styles.page, index !== pageIndex && styles.pageHidden ) }
>
<Text variant="heading-xl" className={ styles.title }>
{ guidePage.title() }
</Text>
<Text variant="body-md" className={ styles.description }>
{ guidePage.description() }
</Text>
{ reservesLearnMore ? (
<div className={ styles.learnMoreRow }>
{ guidePage.learnMore ? (
<LearnMoreLink docsLinksKey={ guidePage.learnMore } />
) : null }
</div>
) : null }
</div>
) ) }
</Dialog.Content>
<Dialog.Footer className={ styles.footer }>
<div className={ styles.footerStart }>
{ ! isFirst ? (
<Button variant="minimal" tone="neutral" onClick={ goBack }>
{ __( 'Back' ) }
</Button>
) : 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. */ }
<Button
variant="minimal"
tone="neutral"
onClick={ goBack }
className={ clsx( styles.back, isFirst && styles.backHidden ) }
>
{ __( 'Back' ) }
</Button>
</div>
<div className={ styles.pager } aria-hidden="true">
{ guide.pages.map( ( _, index ) => (
Expand All @@ -73,7 +123,7 @@ export function OnboardingGuide( { guide, onComplete, onDismiss }: OnboardingGui
) ) }
</div>
<div className={ styles.footerEnd }>
<Button variant="solid" tone="brand" onClick={ goNext }>
<Button ref={ advanceRef } variant="solid" tone="brand" onClick={ goNext }>
{ page.action() }
</Button>
</div>
Expand Down
90 changes: 78 additions & 12 deletions apps/ui/src/components/onboarding-guide/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
display: block;
width: 100%;
aspect-ratio: 320 / 150;
/* The artwork is wider than the slot on a narrow viewport; crop rather than
letterbox so the header never shows bars. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider removing some comments like this one.

object-fit: cover;
/* The popup is a flex column; never let the header art get squeezed when
the viewport height constrains the popup. */
flex-shrink: 0;
Expand All @@ -31,6 +34,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
Expand All @@ -43,35 +60,55 @@
-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;
}

.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;
}

/* Present on every page of a guide that links out anywhere, so a page without a
link still reserves the row. The sizer fills it with plain text, which is the
same single inherited line the link renders as. */
.learnMoreRow {
display: flex;
min-height: 20px;
align-items: center;
}

/* 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). */
Expand All @@ -88,6 +125,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;
}
Expand All @@ -106,8 +158,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 {
Expand All @@ -116,7 +177,12 @@
}

@media (prefers-reduced-motion: reduce) {
.dot {
.dot,
.back {
transition: none;
}

.backHidden {
transition: none;
}
}
Loading