}
+ */
+export function useService() {
+ const service = useRef(/** @type {NextStepsService|null} */ (null));
+ const ntp = useMessaging();
+ useEffect(() => {
+ const stats = new NextStepsService(ntp);
+ service.current = stats;
+ return () => {
+ stats.destroy();
+ };
+ }, [ntp]);
+ return service;
+}
diff --git a/special-pages/pages/new-tab/app/next-steps/components/NextSteps.module.css b/special-pages/pages/new-tab/app/next-steps/components/NextSteps.module.css
new file mode 100644
index 000000000..63af384cd
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/components/NextSteps.module.css
@@ -0,0 +1,188 @@
+.card {
+ background-color: var(--ntp-surface-background-color);
+ border: 1px solid var(--ntp-surface-border-color);
+ padding: var(--sp-2) var(--sp-4);
+ border-radius: var(--sp-3);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: center;
+ text-align: center;
+ max-width: calc(240 * var(--px-in-rem));
+ min-height: calc(150 * var(--px-in-rem));
+ font-size: var(--body-font-size);
+}
+
+.icon {
+ height: 3rem;
+ width: 4rem;
+ margin-bottom: var(--sp-1);
+}
+
+
+
+.title {
+ font-weight: 590;
+ line-height: var(--sp-4);
+ letter-spacing: -0.08px;
+ color: var(--ntp-text-normal);
+ margin-bottom: var(--sp-1);
+}
+
+.description {
+ font-size: calc(11 * var(--px-in-rem));
+ line-height: calc(14 * var(--px-in-rem));
+ letter-spacing: 0.06px;
+ flex-grow: 1;
+ color: var(--ntp-text-muted);
+ margin-bottom: var(--sp-1);
+}
+
+.btn {
+ padding: calc(6 * var(--px-in-rem)) var(--sp-3);
+ background-color: transparent;
+ border-width: 0;
+ border-radius: calc(6 * var(--px-in-rem));
+ text-wrap: nowrap;
+ font-weight: 600;
+ font-size: calc(11 * var(--px-in-rem));
+ line-height: calc(11 * var(--px-in-rem));
+ color: var(--ntp-color-primary);
+
+
+ &:hover {
+ background-color: var(--color-black-at-6);
+ cursor: pointer;
+ }
+
+ &:active {
+ background-color: var(--ntp-color-primary);
+ color: var(--ntp-text-on-primary);
+ }
+
+ &:disabled {
+ color: var(--color-black-at-84);
+ }
+
+ &:disabled:hover {
+ cursor: not-allowed;
+ background-color: var(--color-white-at-6);
+ }
+
+ &:focus-visible {
+ box-shadow: 0px 0px 0px 1px var(--color-white), 0px 0px 0px 3px var(--ntp-focus-outline-color);
+ }
+
+ @media screen and (prefers-color-scheme: dark) {
+ color: var(--color-blue-40);
+
+ &:hover:not(:active) {
+ background-color: var(--color-black-at-9);
+ }
+
+ &:disabled {
+ color: var(--color-white-at-12);
+ opacity: 0.8;
+ }
+
+ &:disabled:hover {
+ cursor: not-allowed;
+ background-color: var(--color-white-at-12);
+ }
+
+ &:focus-visible {
+ box-shadow: 0px 0px 0px 1px var(--ntp-focus-outline-color), 0px 0px 0px 3px var(--color-white);
+ }
+ }
+}
+
+&:hover {
+ .showhideVisible [aria-controls] {
+ opacity: 1;
+ }
+}
+&:focus-within {
+ .showhideVisible [aria-controls] {
+ opacity: 1;
+ }
+}
+
+.dismissBtn {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+}
+
+.cardGroup {
+ height: 100%;
+ width: 100%;
+}
+
+.cardGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--sp-6);
+ margin-bottom: 1px;
+}
+
+.bubble {
+ display: flex;
+ justify-content: flex-start;
+ align-items: flex-start;
+
+ svg path {
+ fill: var(--ntp-color-primary);
+ }
+
+ div {
+ background-color: var(--ntp-color-primary);
+ font-size: calc(11 * var(--px-in-rem));
+
+ height: 20px;
+ line-height: normal;
+ font-weight: 600;
+ letter-spacing: 0.06px;
+ color: var(--ntp-text-on-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ @media screen and (prefers-color-scheme: dark) {
+ svg path {
+ fill: var(--color-blue-40);
+ }
+
+ div {
+ background-color: var(--color-blue-40);
+ }
+ }
+}
+
+
+.showhide {
+ grid-area: showhide;
+ height: 32px;
+ display: grid;
+ justify-items: center;
+
+
+&:hover {
+ &.showhideVisible [aria-controls] {
+ opacity: 1;
+ }
+}
+&:focus-within {
+ &.showhideVisible [aria-controls] {
+ opacity: 1;
+ }
+}
+}
+:root:has(body[data-platform-name="windows"]) {
+ .nextStepsCard .title {
+ font-weight: 700;
+ line-height: 20px;
+ letter-spacing: normal;
+ }
+}
\ No newline at end of file
diff --git a/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js b/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js
new file mode 100644
index 000000000..e4eab3cf0
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js
@@ -0,0 +1,29 @@
+import { h } from 'preact';
+import styles from './NextSteps.module.css';
+import { DismissButton } from '../../components/DismissButton';
+import { variants } from '../nextsteps.data';
+import { useTypedTranslation } from '../../types';
+
+/**
+ * @param {object} props
+ * @param {string} props.type
+ * @param {(id: string) => void} props.dismiss
+ * @param {(id: string) => void} props.action
+ */
+
+export function NextStepsCard({ type, dismiss, action }) {
+ const { t } = useTypedTranslation();
+ const message = variants[type]?.(t);
+ return (
+
+
+
{message.title}
+
{message.summary}
+
+
+
dismiss(message.id)} />
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/next-steps/components/NextStepsExamples.js b/special-pages/pages/new-tab/app/next-steps/components/NextStepsExamples.js
new file mode 100644
index 000000000..bed3605fc
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/components/NextStepsExamples.js
@@ -0,0 +1,95 @@
+import { h } from 'preact';
+
+import { noop } from '../../utils.js';
+import { NextStepsCard } from './NextStepsCard.js';
+import { NextStepsCardGroup, NextStepsBubbleHeader } from './NextStepsGroup.js';
+
+export const nextStepsExamples = {
+ 'next-steps.cardGroup.all': {
+ factory: () => (
+
+ ),
+ },
+ 'next-steps.cardGroup.all-expanded': {
+ factory: () => (
+
+ ),
+ },
+ 'next-steps.cardGroup.two': {
+ factory: () => (
+
+ ),
+ },
+ 'next-steps.cardGroup.one': {
+ factory: () => (
+
+ ),
+ },
+};
+
+export const otherNextStepsExamples = {
+ 'next-steps.bringStuff': {
+ factory: () => ,
+ },
+ 'next-steps.duckplayer': {
+ factory: () => ,
+ },
+ 'next-steps.defaultApp': {
+ factory: () => ,
+ },
+ 'next-steps.emailProtection': {
+ factory: () => ,
+ },
+ 'next-steps.blockCookies': {
+ factory: () => ,
+ },
+ 'next-steps.addAppDockMac': {
+ factory: () => ,
+ },
+ 'next-steps.pinToTaskbarWindows': {
+ factory: () => ,
+ },
+ 'next-steps.bubble': {
+ factory: () => ,
+ },
+};
diff --git a/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js b/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js
new file mode 100644
index 000000000..ec4500be3
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js
@@ -0,0 +1,86 @@
+import { h } from 'preact';
+import cn from 'classnames';
+import styles from './NextSteps.module.css';
+import { useTypedTranslation } from '../../types';
+import { NextStepsCard } from './NextStepsCard';
+import { otherText } from '../nextsteps.data';
+import { ShowHideButtonWithText } from './ShowHideButtonWithText';
+import { useId } from 'preact/hooks';
+
+/**
+ * @typedef {import('../../../../../types/new-tab').Expansion} Expansion
+ * @typedef {import('../../../../../types/new-tab').Animation} Animation
+ * @typedef {import('../../../../../types/new-tab').NextStepsCards} NextStepsCards
+ */
+
+/**
+ * @param {object} props
+ * @param {string[]} props.types
+ * @param {Expansion} props.expansion
+ * @param {()=>void} props.toggle
+ * @param {(id: string)=>void} props.action
+ * @param {(id: string)=>void} props.dismiss
+ */
+export function NextStepsCardGroup({ types, expansion, toggle, action, dismiss }) {
+ const { t } = useTypedTranslation();
+ const WIDGET_ID = useId();
+ const TOGGLE_ID = useId();
+
+ const shownCards = expansion === 'expanded' ? types : types.slice(0, 2);
+ return (
+
+ );
+}
+
+export function NextStepsBubbleHeader() {
+ const { t } = useTypedTranslation();
+ const text = otherText.nextSteps_sectionTitle(t);
+ return (
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/next-steps/components/ShowHide.module.css b/special-pages/pages/new-tab/app/next-steps/components/ShowHide.module.css
new file mode 100644
index 000000000..99762159f
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/components/ShowHide.module.css
@@ -0,0 +1,44 @@
+.button {
+ background: none;
+ border: none;
+ outline: none;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ color: var(--color-black-at-60);
+ height: var(--sp-8);
+ width: 100%;
+ line-height: var(--sp-8);
+ gap: calc(6 * var(--px-in-rem));
+ padding: 0;
+ font-size: calc(11 * var(--px-in-rem));
+
+ /* these are the horizontal lines on either side of the text */
+ span {
+ flex-grow: 1;
+ height: 50%;
+ border-bottom: 1px solid var(--ntp-surface-border-color);
+ margin-bottom: auto;
+ }
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ &[aria-pressed=true] .icon {
+ transform: rotate(180deg);
+ }
+
+ &:focus-visible {
+ outline: 1px dotted var(--ntp-focus-outline-color);
+ }
+
+ @media screen and (prefers-color-scheme: dark) {
+ color: var(--color-white-at-60);
+
+ span {
+ border-bottom-color: var(--color-white-at-24);
+ }
+ }
+}
diff --git a/special-pages/pages/new-tab/app/next-steps/components/ShowHideButtonWithText.jsx b/special-pages/pages/new-tab/app/next-steps/components/ShowHideButtonWithText.jsx
new file mode 100644
index 000000000..c70abbdfa
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/components/ShowHideButtonWithText.jsx
@@ -0,0 +1,22 @@
+import styles from './ShowHide.module.css';
+import { Chevron } from '../../components/Icons';
+import { h } from 'preact';
+
+/**
+ * Function to handle showing or hiding content based on certain conditions.
+ *
+ * @param {Object} props - Input parameters for controlling the behavior of the ShowHide functionality.
+ * @param {string} props.text
+ * @param {() => void} props.onClick
+ * @param {import("preact").ComponentProps<'button'>} [props.buttonAttrs]
+ */
+export function ShowHideButtonWithText({ text, onClick, buttonAttrs = {} }) {
+ return (
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/next-steps/integrations-tests/next-steps.spec.js b/special-pages/pages/new-tab/app/next-steps/integrations-tests/next-steps.spec.js
new file mode 100644
index 000000000..b8feae889
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/integrations-tests/next-steps.spec.js
@@ -0,0 +1,61 @@
+import { test, expect } from '@playwright/test';
+import { NewtabPage } from '../../../integration-tests/new-tab.page.js';
+
+test.describe('newtab NextSteps cards', () => {
+ test('fetches config + next steps data', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ await ntp.reducedMotion();
+ await ntp.openPage({ nextSteps: 'bringStuff' });
+ const calls1 = await ntp.mocks.waitForCallCount({ method: 'initialSetup', count: 1 });
+ const calls2 = await ntp.mocks.waitForCallCount({ method: 'nextSteps_getData', count: 1 });
+ expect(calls1.length).toBe(1);
+ expect(calls2.length).toBe(1);
+ });
+
+ test('renders a dismiss button', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ await ntp.reducedMotion();
+ await ntp.openPage({ nextSteps: 'bringStuff' });
+ await page.getByTestId('dismissBtn').click();
+ await ntp.mocks.waitForCallCount({ method: 'nextSteps_dismiss', count: 1 });
+ });
+
+ test('renders corresponding title text and action text', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ await ntp.reducedMotion();
+ await ntp.openPage({ nextSteps: 'bringStuff' });
+ await page.getByText('Bring Your Stuff').waitFor();
+ await page.getByRole('button', { name: 'Import Now' }).click();
+ await ntp.mocks.waitForCallCount({ method: 'nextSteps_action', count: 1 });
+ await ntp.openPage({ nextSteps: 'defaultApp' });
+ await page.getByText('Set as Default Browser').waitFor();
+ await page.getByRole('button', { name: 'Make Default Browser' }).click();
+ await ntp.mocks.waitForCallCount({ method: 'nextSteps_action', count: 1 });
+ });
+
+ test('renders multiple, shows 2 when collapsed', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ await ntp.reducedMotion();
+ await ntp.openPage({ nextSteps: ['bringStuff', 'defaultApp', 'blockCookies', 'duckplayer'] });
+ // renders the first two
+ await expect(page.getByText('Bring Your Stuff')).toBeVisible();
+ await expect(page.getByText('Set as Default Browser')).toBeVisible();
+ // does not render the 4th one
+ await expect(page.getByRole('button', { name: 'Try DuckPlayer' })).not.toBeVisible();
+ });
+
+ test('renders multiple, shows all when expanded', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ await ntp.reducedMotion();
+ await ntp.openPage({ nextSteps: ['bringStuff', 'defaultApp', 'blockCookies', 'duckplayer'] });
+ // while collapsed, 4th item action button unavailable
+ await expect(page.getByRole('button', { name: 'Try DuckPlayer' })).not.toBeVisible();
+
+ // expand the section
+ await page.getByLabel('Show more', { exact: true }).click();
+
+ await expect(page.locator('p').filter({ hasText: 'Block Cookie Pop-ups' })).toBeVisible();
+ await page.getByRole('button', { name: 'Try DuckPlayer' }).click();
+ await ntp.mocks.waitForCallCount({ method: 'nextSteps_action', count: 1 });
+ });
+});
diff --git a/special-pages/pages/new-tab/app/next-steps/mocks/NextStepsMockProvider.js b/special-pages/pages/new-tab/app/next-steps/mocks/NextStepsMockProvider.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/special-pages/pages/new-tab/app/next-steps/next-steps.service.js b/special-pages/pages/new-tab/app/next-steps/next-steps.service.js
new file mode 100644
index 000000000..57e692f34
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/next-steps.service.js
@@ -0,0 +1,93 @@
+/**
+ * @typedef {import("../../../../types/new-tab.js").NextStepsData} NextStepsData
+ * @typedef {import("../../../../types/new-tab.js").NextStepsConfig} NextStepsConfig
+ */
+import { Service } from '../service.js';
+
+export class NextStepsService {
+ /**
+ * @param {import("../../src/js/index.js").NewTabPage} ntp - The internal data feed, expected to have a `subscribe` method.
+ * @internal
+ */
+ constructor(ntp) {
+ this.ntp = ntp;
+ /** @type {Service} */
+ this.dataService = new Service({
+ initial: () => ntp.messaging.request('nextSteps_getData'),
+ subscribe: (cb) => ntp.messaging.subscribe('nextSteps_onDataUpdate', cb),
+ });
+
+ /** @type {Service} */
+ this.configService = new Service({
+ initial: () => ntp.messaging.request('nextSteps_getConfig'),
+ subscribe: (cb) => ntp.messaging.subscribe('nextSteps_onConfigUpdate', cb),
+ persist: (data) => ntp.messaging.notify('nextSteps_setConfig', data),
+ });
+ }
+
+ /**
+ * @returns {Promise<{data: NextStepsData; config: NextStepsConfig}>}
+ * @internal
+ */
+ async getInitial() {
+ const p1 = this.configService.fetchInitial();
+ const p2 = this.dataService.fetchInitial();
+ const [config, data] = await Promise.all([p1, p2]);
+ return { config, data };
+ }
+
+ /**
+ * @internal
+ */
+ destroy() {
+ this.configService.destroy();
+ this.dataService.destroy();
+ }
+
+ /**
+ * @param {(evt: {data: NextStepsData, source: 'manual' | 'subscription'}) => void} cb
+ * @internal
+ */
+ onData(cb) {
+ return this.dataService.onData(cb);
+ }
+
+ /**
+ * @param {(evt: {data: NextStepsConfig, source: 'manual' | 'subscription'}) => void} cb
+ * @internal
+ */
+ onConfig(cb) {
+ return this.configService.onData(cb);
+ }
+
+ /**
+ * Update the in-memory data immediate and persist.
+ * Any state changes will be broadcast to consumers synchronously
+ * @internal
+ */
+ toggleExpansion() {
+ this.configService.update((old) => {
+ if (old.expansion === 'expanded') {
+ return { ...old, expansion: /** @type {const} */ ('collapsed') };
+ } else {
+ return { ...old, expansion: /** @type {const} */ ('expanded') };
+ }
+ });
+ }
+
+ /**
+ * Dismiss a particular card
+ * @param {string} id
+ */
+ dismiss(id) {
+ this.ntp.messaging.notify('nextSteps_dismiss', { id });
+ }
+
+ /**
+ * Perform a primary action on a card
+ * @param {string} id
+ */
+ action(id) {
+ this.ntp.messaging.notify('nextSteps_action', { id });
+ }
+}
diff --git a/special-pages/pages/new-tab/app/next-steps/next-steps.service.md b/special-pages/pages/new-tab/app/next-steps/next-steps.service.md
new file mode 100644
index 000000000..6aa6e1a6e
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/next-steps.service.md
@@ -0,0 +1,48 @@
+---
+title: Next Steps Cards
+---
+
+## Requests:
+- {@link "NewTab Messages".NextStepsGetDataRequest `nextSteps_getData`}
+ - Used to fetch the initial data (during the first render)
+ - returns {@link "NewTab Messages".NextStepsData}
+- {@link "NewTab Messages".NextStepsGetConfigRequest `nextSteps_getConfig`}
+ - Used to fetch the initial config (during the first render)
+ - returns {@link "NewTab Messages".NextStepsConfig}
+
+## Subscriptions:
+- {@link "NewTab Messages".NextStepsOnDataUpdateSubscription `nextSteps_onDataUpdate`}.
+ - The messages available for the platform
+ - returns {@link "NewTab Messages".NextStepsData}
+- {@link "NewTab Messages".NextStepsOnConfigUpdateSubscription `nextSteps_onConfigUpdate`}.
+ - The widget config
+ - returns {@link "NewTab Messages".NextStepsConfig}
+
+## Notifications:
+- {@link "NewTab Messages".NextStepsActionNotification `nextSteps_action`}
+ - Sent when the user clicks the action button
+ - sends {@link "NewTab Messages".NextStepsActionNotify}
+ - example payload:
+ ```json
+ {
+ "id": "defaultApp"
+ }
+ ```
+- {@link "NewTab Messages".NextStepsDismissNotification `nextSteps_dismiss`}
+ - Sent when the user clicks the dismiss button
+ - sends {@link "NewTab Messages".NextStepsDismissNotify}
+ - example payload:
+ ```json
+ {
+ "id": "defaultApp"
+ }
+ ```
+- {@link "NewTab Messages".NextStepsSetConfigNotification `nextSteps_setConfig`}
+ - Sent when the user toggles the expansion of the next steps
+ - sends {@link "NewTab Messages".NextStepsConfig}
+ - example payload:
+ ```json
+ {
+ "expansion": "collapsed"
+ }
+ ```
diff --git a/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js b/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js
new file mode 100644
index 000000000..80910df26
--- /dev/null
+++ b/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js
@@ -0,0 +1,67 @@
+export const variants = {
+ /** @param {(translationId: string) => string} t */
+ bringStuff: (t) => ({
+ id: 'bringStuff',
+ icon: 'Bring-Stuff',
+ title: t('nextSteps_bringStuff_title'),
+ summary: t('nextSteps_bringStuff_summary'),
+ actionText: t('nextSteps_bringStuff_actionText'),
+ }),
+ /** @param {(translationId: string) => string} t */
+ defaultApp: (t) => ({
+ id: 'defaultApp',
+ icon: 'Default-App',
+ title: t('nextSteps_defaultApp_title'),
+ summary: t('nextSteps_defaultApp_summary'),
+ actionText: t('nextSteps_defaultApp_actionText'),
+ }),
+ /** @param {(translationId: string) => string} t */
+ blockCookies: (t) => ({
+ id: 'blockCookies',
+ icon: 'Cookie-Pops',
+ title: t('nextSteps_blockCookies_title'),
+ summary: t('nextSteps_blockCookies_summary'),
+ actionText: t('nextSteps_blockCookies_actionText'),
+ }),
+ /** @param {(translationId: string) => string} t */
+ emailProtection: (t) => ({
+ id: 'emailProtection',
+ icon: 'Email-Protection',
+ title: t('nextSteps_emailProtection_title'),
+ summary: t('nextSteps_emailProtection_summary'),
+ actionText: t('nextSteps_emailProtection_actionText'),
+ }),
+ /** @param {(translationId: string) => string} t */
+ duckplayer: (t) => ({
+ id: 'duckplayer',
+ icon: 'Tube-Clean',
+ title: t('nextSteps_duckPlayer_title'),
+ summary: t('nextSteps_duckPlayer_summary'),
+ actionText: t('nextSteps_duckPlayer_actionText'),
+ }),
+ /** @param {(translationId: string) => string} t */
+ addAppDockMac: (t) => ({
+ id: 'addAppToDockMac',
+ icon: 'Dock-Add-Mac',
+ title: t('nextSteps_addAppDockMac_title'),
+ summary: t('nextSteps_addAppDockMac_summary'),
+ actionText: t('nextSteps_addAppDockMac_actionText'),
+ }),
+ /** @param {(translationId: string) => string} t */
+ pinAppToTaskbarWindows: (t) => ({
+ id: 'pinAppToTaskbarWindows',
+ icon: 'Dock-Add-Windows',
+ title: t('nextSteps_pinAppToTaskbarWindows_title'),
+ summary: t('nextSteps_pinAppToTaskbarWindows_summary'),
+ actionText: t('nextSteps_pinAppToTaskbarWindows_actionText'),
+ }),
+};
+
+export const otherText = {
+ /** @param {(translationId: string) => string} t */
+ showMore: (t) => t('ntp_show_more'),
+ /** @param {(translationId: string) => string} t */
+ showLess: (t) => t('ntp_show_less'),
+ /** @param {(translationId: string) => string} t */
+ nextSteps_sectionTitle: (t) => t('nextSteps_sectionTitle'),
+};
diff --git a/special-pages/pages/new-tab/app/remote-messaging-framework/RemoteMessagingFramework.js b/special-pages/pages/new-tab/app/remote-messaging-framework/RemoteMessagingFramework.js
index 388b7aca4..b92e7fe91 100644
--- a/special-pages/pages/new-tab/app/remote-messaging-framework/RemoteMessagingFramework.js
+++ b/special-pages/pages/new-tab/app/remote-messaging-framework/RemoteMessagingFramework.js
@@ -3,7 +3,7 @@ import cn from 'classnames';
import styles from './RemoteMessagingFramework.module.css';
import { useContext } from 'preact/hooks';
import { RMFContext } from './RMFProvider.js';
-import { Cross } from '../components/Icons.js';
+import { DismissButton } from '../components/DismissButton';
/**
* @import { RMFMessage } from "../../../../types/new-tab"
@@ -48,9 +48,7 @@ export function RemoteMessagingFramework({ message, primaryAction, secondaryActi
)}
-
+ dismiss(id)} />
);
}
diff --git a/special-pages/pages/new-tab/app/remote-messaging-framework/RemoteMessagingFramework.module.css b/special-pages/pages/new-tab/app/remote-messaging-framework/RemoteMessagingFramework.module.css
index 850fbb6dd..751132640 100644
--- a/special-pages/pages/new-tab/app/remote-messaging-framework/RemoteMessagingFramework.module.css
+++ b/special-pages/pages/new-tab/app/remote-messaging-framework/RemoteMessagingFramework.module.css
@@ -161,35 +161,4 @@
position: absolute;
top: 0.5rem;
right: 0.5rem;
- height: 1rem;
- width: 1rem;
- padding: 0;
- line-height: 1;
- font-size: 12px;
- background-color: transparent;
- color: var(--color-black-at-60);
- border: none;
- border-radius: 50%;
-
- &:active {
- background-color: var(--color-black-at-18);
- color: var(--color-black-at-84);
- }
-
- &:hover {
- background-color: var(--color-black-at-9);
- }
-
- @media screen and (prefers-color-scheme: dark) {
- color: var(--color-white-at-60);
-
- &:hover {
- background-color: var(--color-white-at-9);
- }
-
- &:active {
- background-color: var(--color-white-at-18);
- color: var(--color-white-at-84);
- }
- }
}
diff --git a/special-pages/pages/new-tab/app/remote-messaging-framework/integration-tests/rmf.spec.js b/special-pages/pages/new-tab/app/remote-messaging-framework/integration-tests/rmf.spec.js
index a55d713b1..778ec7925 100644
--- a/special-pages/pages/new-tab/app/remote-messaging-framework/integration-tests/rmf.spec.js
+++ b/special-pages/pages/new-tab/app/remote-messaging-framework/integration-tests/rmf.spec.js
@@ -20,7 +20,7 @@ test.describe('newtab remote messaging framework rmf', () => {
await ntp.openPage({ rmf: 'small' });
await page.getByText('Search services limited').waitFor();
- await page.getByLabel('Close').click();
+ await page.getByTestId('dismissBtn').click();
await ntp.mocks.waitForCallCount({ method: 'rmf_dismiss', count: 1 });
});
diff --git a/special-pages/pages/new-tab/app/styles/ntp-theme.css b/special-pages/pages/new-tab/app/styles/ntp-theme.css
index 983dc082d..915b79d43 100644
--- a/special-pages/pages/new-tab/app/styles/ntp-theme.css
+++ b/special-pages/pages/new-tab/app/styles/ntp-theme.css
@@ -3,6 +3,9 @@
--ntp-surface-background-color: white;
--ntp-surface-border-color: var(--color-black-at-6);
--ntp-text-normal: var(--color-black-at-84);
+ --ntp-text-muted: var(--color-black-at-60);
+ --ntp-text-on-primary: var(--color-white-at-84);
+ --ntp-color-primary: var(--ddg-color-primary);
/* Mac/System/Body */
--body-font-size: 13px;
@@ -25,8 +28,11 @@
@media (prefers-color-scheme: dark) {
--ntp-background-color: var(--color-gray-85);
--ntp-surface-background-color: #2a2a2a;
- --ntp-surface-border-color: var(--color-black-at-6);
- --ntp-text-normal: white;
+ --ntp-surface-border-color: var(--color-white-at-6);
+ --ntp-text-normal: var(--color-white-at-84);
+ --ntp-text-muted: var(--color-white-at-60);
+ --ntp-color-primary: var(--color-blue-30);
+ --ntp-text-on-primary: var(--color-black-at-84);
--ntp-focus-outline-color: white;
}
}
diff --git a/special-pages/pages/new-tab/integration-tests/new-tab.page.js b/special-pages/pages/new-tab/integration-tests/new-tab.page.js
index 77ebbc32e..6ae04037b 100644
--- a/special-pages/pages/new-tab/integration-tests/new-tab.page.js
+++ b/special-pages/pages/new-tab/integration-tests/new-tab.page.js
@@ -28,8 +28,9 @@ export class NewtabPage {
requestImport: {},
/** @type {import('../../../types/new-tab.ts').InitialSetupResponse} */
initialSetup: {
- widgets: [{ id: 'rmf' }, { id: 'favorites' }, { id: 'privacyStats' }],
+ widgets: [{ id: 'rmf' }, { id: 'nextSteps' }, { id: 'favorites' }, { id: 'privacyStats' }],
widgetConfigs: [
+ { id: 'nextSteps', visibility: 'visible' },
{ id: 'favorites', visibility: 'visible' },
{ id: 'privacyStats', visibility: 'visible' },
],
@@ -42,7 +43,8 @@ export class NewtabPage {
},
stats_getConfig: {},
stats_getData: {},
- rmf_getConfig: {},
+ nextSteps_getConfig: {},
+ nextSteps_getData: {},
rmf_getData: {},
widgets_setConfig: {},
});
@@ -51,16 +53,16 @@ export class NewtabPage {
/**
* Opens a page with optional parameters.
* This method ensures that mocks are installed and routes are set up before navigating to the page.
- *
* @param {Object} [params] - Optional parameters for opening the page.
* @param {'debug' | 'production'} [params.mode] - Optional parameters for opening the page.
* @param {boolean} [params.willThrow] - Optional flag to simulate an exception
* @param {string|number} [params.favorites] - Optional flag to preload a list of favorites
- * @param {string} [params.rmf] - Optional flag to point to display=components view with certain rmf example visible
+ * @param {string|string[]} [params.nextSteps] - Optional flag to load Next Steps cards
+ * @param {string} [params.rmf] - Optional flag to add certain rmf example
* @param {string} [params.updateNotification] - Optional flag to point to display=components view with certain rmf example visible
* @param {string} [params.platformName] - Optional parameters for opening the page.
*/
- async openPage({ mode = 'debug', platformName, willThrow = false, favorites, rmf, updateNotification } = {}) {
+ async openPage({ mode = 'debug', platformName, willThrow = false, favorites, nextSteps, rmf, updateNotification } = {}) {
await this.mocks.install();
const searchParams = new URLSearchParams({ mode, willThrow: String(willThrow) });
@@ -72,6 +74,16 @@ export class NewtabPage {
searchParams.set('rmf', rmf);
}
+ if (nextSteps !== undefined) {
+ if (typeof nextSteps === 'string') {
+ searchParams.set('next-steps', nextSteps);
+ } else if (Array.isArray(nextSteps)) {
+ for (const step of nextSteps) {
+ searchParams.append('next-steps', step);
+ }
+ }
+ }
+
if (platformName !== undefined) {
searchParams.set('platform', platformName);
}
diff --git a/special-pages/pages/new-tab/src/icons/Bring-Stuff-128.svg b/special-pages/pages/new-tab/src/icons/Bring-Stuff-128.svg
new file mode 100644
index 000000000..f682f82fa
--- /dev/null
+++ b/special-pages/pages/new-tab/src/icons/Bring-Stuff-128.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/special-pages/pages/new-tab/src/icons/Cookie-Pops-128.svg b/special-pages/pages/new-tab/src/icons/Cookie-Pops-128.svg
new file mode 100644
index 000000000..174b68742
--- /dev/null
+++ b/special-pages/pages/new-tab/src/icons/Cookie-Pops-128.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/special-pages/pages/new-tab/src/icons/Default-App-128.svg b/special-pages/pages/new-tab/src/icons/Default-App-128.svg
new file mode 100644
index 000000000..1e65a4156
--- /dev/null
+++ b/special-pages/pages/new-tab/src/icons/Default-App-128.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/special-pages/pages/new-tab/src/icons/Dock-Add-Mac-128.svg b/special-pages/pages/new-tab/src/icons/Dock-Add-Mac-128.svg
new file mode 100644
index 000000000..5251629bc
--- /dev/null
+++ b/special-pages/pages/new-tab/src/icons/Dock-Add-Mac-128.svg
@@ -0,0 +1,19 @@
+
\ No newline at end of file
diff --git a/special-pages/pages/new-tab/src/icons/Dock-Add-Windows-128.svg b/special-pages/pages/new-tab/src/icons/Dock-Add-Windows-128.svg
new file mode 100644
index 000000000..a848aa498
--- /dev/null
+++ b/special-pages/pages/new-tab/src/icons/Dock-Add-Windows-128.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/special-pages/pages/new-tab/src/icons/Email-Protection-128.svg b/special-pages/pages/new-tab/src/icons/Email-Protection-128.svg
new file mode 100644
index 000000000..22a7ce6a4
--- /dev/null
+++ b/special-pages/pages/new-tab/src/icons/Email-Protection-128.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/special-pages/pages/new-tab/src/icons/Tube-Clean-128.svg b/special-pages/pages/new-tab/src/icons/Tube-Clean-128.svg
new file mode 100644
index 000000000..5e4e45ead
--- /dev/null
+++ b/special-pages/pages/new-tab/src/icons/Tube-Clean-128.svg
@@ -0,0 +1,7 @@
+
diff --git a/special-pages/pages/new-tab/src/js/mock-transport.js b/special-pages/pages/new-tab/src/js/mock-transport.js
index c67c7de51..7b385100d 100644
--- a/special-pages/pages/new-tab/src/js/mock-transport.js
+++ b/special-pages/pages/new-tab/src/js/mock-transport.js
@@ -4,12 +4,16 @@ import { stats } from '../../app/privacy-stats/mocks/stats.js';
import { rmfDataExamples } from '../../app/remote-messaging-framework/mocks/rmf.data.js';
import { favorites, gen } from '../../app/favorites/mocks/favorites.data.js';
import { updateNotificationExamples } from '../../app/update-notification/mocks/update-notification.data.js';
+import { variants as nextSteps } from '../../app/next-steps/nextsteps.data.js';
/**
* @typedef {import('../../../../types/new-tab').Favorite} Favorite
* @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData
* @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig
* @typedef {import('../../../../types/new-tab').StatsConfig} StatsConfig
+ * @typedef {import('../../../../types/new-tab').NextStepsConfig} NextStepsConfig
+ * @typedef {import('../../../../types/new-tab').NextStepsCards} NextStepsCards
+ * @typedef {import('../../../../types/new-tab').NextStepsData} NextStepsData
* @typedef {import('../../../../types/new-tab').UpdateNotificationData} UpdateNotificationData
* @typedef {import('../../../../types/new-tab.js').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionNames
*/
@@ -290,6 +294,33 @@ export function mockTransport() {
}
return Promise.resolve(fromStorage);
}
+ case 'nextSteps_getConfig': {
+ /** @type {NextStepsConfig} */
+ const config = { expansion: 'collapsed' };
+ return Promise.resolve(config);
+ }
+ case 'nextSteps_getData': {
+ /** @type {NextStepsData} */
+ let data = { content: null };
+ const ids = url.searchParams.getAll('next-steps');
+ if (ids.length) {
+ /** @type {NextStepsData} */
+ data = {
+ content: ids
+ .filter((id) => {
+ if (!(id in nextSteps)) {
+ console.warn(`${id} missing in nextSteps data`);
+ return false;
+ }
+ return true;
+ })
+ .map((id) => {
+ return { id: /** @type {any} */ (id) };
+ }),
+ };
+ }
+ return Promise.resolve(data);
+ }
case 'rmf_getData': {
/** @type {import('../../../../types/new-tab.js').RMFData} */
let message = { content: undefined };
@@ -329,6 +360,7 @@ export function mockTransport() {
}
case 'initialSetup': {
const widgetsFromStorage = read('widgets') || [
+ { id: 'nextSteps' },
{ id: 'updateNotification' },
{ id: 'rmf' },
{ id: 'favorites' },
diff --git a/special-pages/pages/new-tab/src/locales/en/newtab.json b/special-pages/pages/new-tab/src/locales/en/newtab.json
index d0693ddf5..06df9a462 100644
--- a/special-pages/pages/new-tab/src/locales/en/newtab.json
+++ b/special-pages/pages/new-tab/src/locales/en/newtab.json
@@ -9,6 +9,18 @@
}
]
},
+ "ntp_show_less": {
+ "title": "Show less",
+ "note": "Text for the Expansion of a section on NTP"
+ },
+ "ntp_show_more": {
+ "title": "Show more",
+ "note": "Text for the Expansion of a section on NTP"
+ },
+ "ntp_dismiss": {
+ "title": "Dismiss",
+ "note": "Text for all dismiss buttons on NTP"
+ },
"widgets_visibility_menu_title": {
"title": "Customize New Tab Page",
"note": "Heading text describing that there's a list of toggles for customizing the page layout."
@@ -72,5 +84,101 @@
"updateNotification_dismiss_btn": {
"title": "Dismiss",
"note": "Button label text for an action that removes the widget from the screen."
+ },
+ "nextSteps_sectionTitle": {
+ "title": "Next Steps",
+ "note": "Text that goes in the Next Steps bubble above the first card"
+ },
+ "nextSteps_bringStuff_title": {
+ "title": "Bring Your Stuff",
+ "note": "Title of the Next Steps card for importing bookmarks and favorites"
+ },
+ "nextSteps_bringStuff_summary": {
+ "title": "Import bookmarks, favorites, and passwords for a smooth transition from your old browser.",
+ "note": "Summary of the Next Steps card for importing bookmarks and favorites"
+ },
+ "nextSteps_bringStuff_actionText": {
+ "title": "Import Now",
+ "note": "Button text of the Next Steps card for importing bookmarks and favorites"
+ },
+ "nextSteps_defaultApp_title": {
+ "title": "Set as Default Browser",
+ "note": "Title of the Next Steps card for making DDG the user's default browser"
+ },
+ "nextSteps_defaultApp_summary": {
+ "title": "We automatically block trackers as you browse. It’s privacy, simplified.",
+ "note": "Summary of the Next Steps card for making DDG the user's default browser"
+ },
+ "nextSteps_defaultApp_actionText": {
+ "title": "Make Default Browser",
+ "note": "Button text of the Next Steps card for making DDG the user's default browser"
+ },
+ "nextSteps_blockCookies_title": {
+ "title": "Block Cookie Pop-ups",
+ "note": "Title of the Next Steps card for blocking cookie pop-ups"
+ },
+ "nextSteps_blockCookies_summary": {
+ "title": "We need your permission to say no to cookies on your behalf. Easy choice.",
+ "note": "Summary of the Next Steps card for blocking cookie pop-ups"
+ },
+ "nextSteps_blockCookies_actionText": {
+ "title": "Block Cookie Pop-ups",
+ "note": "Button text of the Next Steps card for blocking cookie pop-ups"
+ },
+ "nextSteps_emailProtection_title": {
+ "title": "Protect Your Inbox",
+ "note": "Title of the Next Steps card for email protection"
+ },
+ "nextSteps_emailProtection_summary": {
+ "title": "Generate @duck.com addresses that remove trackers from email and forwards to your inbox.",
+ "note": "Summary of the Next Steps card for email protection"
+ },
+ "nextSteps_emailProtection_actionText": {
+ "title": "Get Email Protection",
+ "note": "Button text of the Next Steps card for email protection"
+ },
+ "nextSteps_duckPlayer_title": {
+ "title": "YouTube Without Creepy Ads",
+ "note": "Title of the Next Steps card for adopting DuckPlayer"
+ },
+ "nextSteps_duckPlayer_summary": {
+ "title": "Enjoy a clean viewing experience without personalized ads.",
+ "note": "Summary of the Next Steps card for adopting DuckPlayer"
+ },
+ "nextSteps_duckPlayer_actionText": {
+ "title": "Try DuckPlayer",
+ "note": "Button text of the Next Steps card for adopting DuckPlayer"
+ },
+ "nextSteps_addAppDockMac_title": {
+ "title": "Add App to the Dock",
+ "note": "Title of the Next Steps card for adding DDG app to OS dock"
+ },
+ "nextSteps_addAppDockMac_summary": {
+ "title": "Access DuckDuckGo faster by adding it to the Dock.",
+ "note": "Summary of the Next Steps card for adding DDG app to OS dock"
+ },
+ "nextSteps_addAppDockMac_actionText": {
+ "title": "Add to Dock",
+ "note": "Initial button text of the Next Steps card for adding DDG app to OS dock"
+ },
+ "nextSteps_addAppDockMac_confirmationText": {
+ "title": "Added to Dock!",
+ "note": "Button text after clicking on the Next Steps card for adding DDG app to OS dock"
+ },
+ "nextSteps_pinAppToTaskbarWindows_title": {
+ "title": "Pin App to the Taskbar",
+ "note": "Title of the Next Steps card for adding DDG app to OS dock"
+ },
+ "nextSteps_pinAppToTaskbarWindows_summary": {
+ "title": "Access DuckDuckGo faster by pinning it to the Taskbar.",
+ "note": "Summary of the Next Steps card for adding DDG app to OS dock"
+ },
+ "nextSteps_pinAppToTaskbarWindows_actionText": {
+ "title": "Pin to Taskbar",
+ "note": "Initial button text of the Next Steps card for adding DDG app to OS dock"
+ },
+ "nextSteps_pinAppToTaskbarWindows_confirmationText": {
+ "title": "Pinned to Taskbar!",
+ "note": "Button text after clicking on the Next Steps card for adding DDG app to OS dock"
}
-}
+}
\ No newline at end of file
diff --git a/special-pages/playwright.config.js b/special-pages/playwright.config.js
index 8c5c2a74f..47f158d73 100644
--- a/special-pages/playwright.config.js
+++ b/special-pages/playwright.config.js
@@ -16,6 +16,7 @@ export default defineConfig({
name: 'integration',
// prettier-ignore
testMatch: [
+ 'next-steps.spec.js',
'privacy-stats.spec.js',
'rmf.spec.js',
'new-tab.spec.js',
diff --git a/special-pages/types/new-tab.ts b/special-pages/types/new-tab.ts
index 05258421e..3783ace32 100644
--- a/special-pages/types/new-tab.ts
+++ b/special-pages/types/new-tab.ts
@@ -26,6 +26,16 @@ export type WidgetConfigs = WidgetConfigItem[];
* An ordered list of supported Widgets. Use this to communicate what's supported
*/
export type Widgets = WidgetListItem[];
+export type NextStepsCards = {
+ id:
+ | "bringStuff"
+ | "defaultApp"
+ | "blockCookies"
+ | "emailProtection"
+ | "duckplayer"
+ | "addAppToDockMac"
+ | "pinAppToTaskbarWindows";
+}[];
export type RMFMessage = SmallMessage | MediumMessage | BigSingleActionMessage | BigTwoActionMessage;
export type RMFIcon = "Announce" | "DDGAnnounce" | "CriticalUpdate" | "AppUpdate" | "PrivacyPro";
@@ -40,6 +50,9 @@ export interface NewTabMessages {
| FavoritesOpenNotification
| FavoritesOpenContextMenuNotification
| FavoritesSetConfigNotification
+ | NextStepsActionNotification
+ | NextStepsDismissNotification
+ | NextStepsSetConfigNotification
| ReportInitExceptionNotification
| ReportPageExceptionNotification
| RmfDismissNotification
@@ -52,12 +65,16 @@ export interface NewTabMessages {
| FavoritesGetConfigRequest
| FavoritesGetDataRequest
| InitialSetupRequest
+ | NextStepsGetConfigRequest
+ | NextStepsGetDataRequest
| RmfGetDataRequest
| StatsGetConfigRequest
| StatsGetDataRequest;
subscriptions:
| FavoritesOnConfigUpdateSubscription
| FavoritesOnDataUpdateSubscription
+ | NextStepsOnConfigUpdateSubscription
+ | NextStepsOnDataUpdateSubscription
| RmfOnDataUpdateSubscription
| StatsOnConfigUpdateSubscription
| StatsOnDataUpdateSubscription
@@ -157,6 +174,37 @@ export interface ViewTransitions {
export interface Auto {
kind: "auto-animate";
}
+/**
+ * Generated from @see "../messages/new-tab/nextSteps_action.notify.json"
+ */
+export interface NextStepsActionNotification {
+ method: "nextSteps_action";
+ params: NextStepsActionNotify;
+}
+export interface NextStepsActionNotify {
+ id: string;
+}
+/**
+ * Generated from @see "../messages/new-tab/nextSteps_dismiss.notify.json"
+ */
+export interface NextStepsDismissNotification {
+ method: "nextSteps_dismiss";
+ params: NextStepsDismissNotify;
+}
+export interface NextStepsDismissNotify {
+ id: string;
+}
+/**
+ * Generated from @see "../messages/new-tab/nextSteps_setConfig.notify.json"
+ */
+export interface NextStepsSetConfigNotification {
+ method: "nextSteps_setConfig";
+ params: NextStepsConfig;
+}
+export interface NextStepsConfig {
+ expansion: Expansion;
+ animation?: Animation;
+}
/**
* Generated from @see "../messages/new-tab/reportInitException.notify.json"
*/
@@ -295,6 +343,23 @@ export interface UpdateNotification {
version: string;
notes: string[];
}
+/**
+ * Generated from @see "../messages/new-tab/nextSteps_getConfig.request.json"
+ */
+export interface NextStepsGetConfigRequest {
+ method: "nextSteps_getConfig";
+ result: NextStepsConfig;
+}
+/**
+ * Generated from @see "../messages/new-tab/nextSteps_getData.request.json"
+ */
+export interface NextStepsGetDataRequest {
+ method: "nextSteps_getData";
+ result: NextStepsData;
+}
+export interface NextStepsData {
+ content: null | NextStepsCards;
+}
/**
* Generated from @see "../messages/new-tab/rmf_getData.request.json"
*/
@@ -377,6 +442,20 @@ export interface FavoritesOnDataUpdateSubscription {
subscriptionEvent: "favorites_onDataUpdate";
params: FavoritesData;
}
+/**
+ * Generated from @see "../messages/new-tab/nextSteps_onConfigUpdate.subscribe.json"
+ */
+export interface NextStepsOnConfigUpdateSubscription {
+ subscriptionEvent: "nextSteps_onConfigUpdate";
+ params: NextStepsConfig;
+}
+/**
+ * Generated from @see "../messages/new-tab/nextSteps_onDataUpdate.subscribe.json"
+ */
+export interface NextStepsOnDataUpdateSubscription {
+ subscriptionEvent: "nextSteps_onDataUpdate";
+ params: NextStepsData;
+}
/**
* Generated from @see "../messages/new-tab/rmf_onDataUpdate.subscribe.json"
*/