diff --git a/special-pages/pages/new-tab/app/components/BackgroundProvider.js b/special-pages/pages/new-tab/app/components/BackgroundProvider.js
index 947313cde..6c8fc4ca0 100644
--- a/special-pages/pages/new-tab/app/components/BackgroundProvider.js
+++ b/special-pages/pages/new-tab/app/components/BackgroundProvider.js
@@ -1,7 +1,9 @@
-import { h } from 'preact';
+import { Fragment, h } from 'preact';
import styles from './BackgroundReceiver.module.css';
+import { values } from '../customizer/values.js';
import { useContext } from 'preact/hooks';
import { CustomizerContext } from '../customizer/CustomizerProvider.js';
+import { detectThemeFromHex } from '../customizer/utils.js';
/**
* @import { BackgroundVariant, BrowserTheme } from "../../types/new-tab"
@@ -18,12 +20,22 @@ export function inferSchemeFrom(background, browserTheme, system) {
switch (background.kind) {
case 'default':
return { bg: browser, browser };
- case 'gradient':
+ case 'color': {
+ const color = values.colors[background.value];
+ return { bg: color.colorScheme, browser };
+ }
+
+ case 'gradient': {
+ const gradient = values.gradients[background.value];
+ return { bg: gradient.colorScheme, browser };
+ }
+
case 'userImage':
+ return { bg: background.value.colorScheme, browser };
+
case 'hex':
- console.log('not supported yet!');
+ return { bg: detectThemeFromHex(background.value), browser };
}
- return { bg: browser, browser };
}
/**
@@ -50,13 +62,79 @@ export function BackgroundConsumer({ browser }) {
case 'default': {
return
;
}
- case 'hex':
- case 'color':
- case 'gradient':
- case 'userImage':
+ case 'hex': {
+ return (
+
+ );
+ }
+ case 'color': {
+ const color = values.colors[background.value];
+ return (
+
+ );
+ }
+ case 'gradient': {
+ const gradient = values.gradients[background.value];
+ return (
+
+
+
+
+ );
+ }
+ case 'userImage': {
+ const img = background.value;
+ return (
+
+ );
+ }
default: {
- console.warn('not supported yet!');
- return ;
+ console.warn('Unreachable!');
+ return ;
}
}
}
diff --git a/special-pages/pages/new-tab/app/customizer/CustomizerProvider.js b/special-pages/pages/new-tab/app/customizer/CustomizerProvider.js
index 601c15313..e007818fb 100644
--- a/special-pages/pages/new-tab/app/customizer/CustomizerProvider.js
+++ b/special-pages/pages/new-tab/app/customizer/CustomizerProvider.js
@@ -1,9 +1,15 @@
import { createContext, h } from 'preact';
-import { signal, useSignal } from '@preact/signals';
+import { useCallback } from 'preact/hooks';
+import { effect, signal, useSignal } from '@preact/signals';
import { useThemes } from './themes.js';
/**
* @typedef {import('../../types/new-tab.js').CustomizerData} CustomizerData
+ * @typedef {import('../../types/new-tab.js').BackgroundData} BackgroundData
+ * @typedef {import('../../types/new-tab.js').ThemeData} ThemeData
+ * @typedef {import('../../types/new-tab.js').UserImageData} UserImageData
+ * @typedef {import('../service.hooks.js').State} State
+ * @typedef {import('../service.hooks.js').Events} Events
*/
/**
@@ -24,6 +30,17 @@ export const CustomizerContext = createContext({
userColor: null,
theme: 'system',
}),
+ /** @type {(bg: BackgroundData) => void} */
+ select: (bg) => {},
+ upload: () => {},
+ /**
+ * @type {(theme: ThemeData) => void}
+ */
+ setTheme: (theme) => {},
+ /**
+ * @type {(id: string) => void}
+ */
+ deleteImage: (id) => {},
});
/**
@@ -40,10 +57,56 @@ export function CustomizerProvider({ service, initialData, children }) {
const data = useSignal(initialData);
const { main, browser } = useThemes(data);
- // todo: add data subscriptions here
+ effect(() => {
+ const unsub = service.onBackground((evt) => {
+ data.value = { ...data.value, background: evt.data.background };
+ });
+ const unsub1 = service.onTheme((evt) => {
+ data.value = { ...data.value, theme: evt.data.theme };
+ });
+ const unsub2 = service.onImages((evt) => {
+ data.value = { ...data.value, userImages: evt.data.userImages };
+ });
+ const unsub3 = service.onColor((evt) => {
+ data.value = { ...data.value, userColor: evt.data.userColor };
+ });
+
+ return () => {
+ unsub();
+ unsub1();
+ unsub2();
+ unsub3();
+ };
+ });
+
+ /** @type {(bg: BackgroundData) => void} */
+ const select = useCallback(
+ (bg) => {
+ service.setBackground(bg);
+ },
+ [service],
+ );
+
+ const upload = useCallback(() => {
+ service.upload();
+ }, [service]);
+
+ const setTheme = useCallback(
+ (theme) => {
+ service.setTheme(theme);
+ },
+ [service],
+ );
+
+ const deleteImage = useCallback(
+ (id) => {
+ service.deleteImage(id);
+ },
+ [service],
+ );
return (
-
+
{children}
);
diff --git a/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js b/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js
new file mode 100644
index 000000000..badce3333
--- /dev/null
+++ b/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js
@@ -0,0 +1,242 @@
+import { h, Fragment } from 'preact';
+import cn from 'classnames';
+
+import { values } from '../values.js';
+import styles from './CustomizerDrawerInner.module.css';
+import { CircleCheck, PlusIcon } from '../../components/Icons.js';
+import { useComputed } from '@preact/signals';
+import { useContext, useId } from 'preact/hooks';
+import { CustomizerThemesContext } from '../CustomizerProvider.js';
+import { detectThemeFromHex } from '../utils.js';
+
+/**
+ * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData } from '../../../types/new-tab.js'
+ */
+
+/**
+ * @param {object} props
+ * @param {(bg: BackgroundData) => void} props.select
+ * @param {import('@preact/signals').Signal} props.data
+ * @param {(target: 'color' | 'back' | 'image' | 'gradient') => void} props.onNav
+ * @param {() => void} props.onUpload
+ */
+export function BackgroundSection({ data, onNav, onUpload, select }) {
+ const { browser } = useContext(CustomizerThemesContext);
+ let displayColor;
+
+ if (data.value.background.kind === 'color') {
+ displayColor = values.colors[data.value.background.value];
+ } else if (data.value.background.kind === 'hex') {
+ const hex = data.value.background.value;
+ displayColor = { hex: data.value.background.value, colorScheme: detectThemeFromHex(hex) };
+ } else {
+ displayColor = values.colors.color11;
+ }
+
+ /** @type {{path: string; colorScheme: 'light' | 'dark'}} */
+ let gradient;
+ if (data.value.background.kind === 'gradient') {
+ gradient = values.gradients[data.value.background.value];
+ } else {
+ gradient = values.gradients.gradient02;
+ }
+
+ return (
+
+
Background
+
+ -
+ select({ background: { kind: 'default' } })}
+ />
+
+ -
+ onNav('color')}
+ />
+
+ -
+ onNav('gradient')}
+ />
+
+ -
+ onNav('image')}
+ data={data}
+ upload={onUpload}
+ browserTheme={browser}
+ />
+
+
+
+ );
+}
+
+/**
+ * @param {object} props
+ * @param {boolean} props.checked
+ * @param {() => void} props.onClick
+ */
+function DefaultPanel({ checked, onClick }) {
+ const id = useId();
+ const { main } = useContext(CustomizerThemesContext);
+
+ return (
+ <>
+
+ Default
+ >
+ );
+}
+
+/**
+ * @param {object} props
+ * @param {boolean} props.checked
+ * @param {() => void} props.onClick
+ * @param {{hex: string, colorScheme: 'light' | 'dark'}} props.color
+ */
+function ColorPanel(props) {
+ const id = useId();
+ return (
+ <>
+
+ Solid Colors
+ >
+ );
+}
+
+/**
+ * @param {object} props
+ * @param {boolean} props.checked
+ * @param {() => void} props.onClick
+ * @param {{path: string; colorScheme: 'light' | 'dark'}} props.gradient
+ */
+function GradientPanel(props) {
+ const id = useId();
+ return (
+ <>
+
+ Gradients
+ >
+ );
+}
+
+/**
+ * @param {object} props
+ * @param {boolean} props.checked
+ * @param {() => void} props.onClick
+ * @param {() => void} props.upload
+ * @param {import('@preact/signals').Signal} props.data
+ * @param {import('@preact/signals').Signal<'light' | 'dark'>} props.browserTheme
+ */
+function BackgroundImagePanel(props) {
+ const id = useId();
+ const empty = useComputed(() => props.data.value.userImages.length === 0);
+ const selectedImage = useComputed(() => {
+ const imageId = props.data.value.background.kind === 'userImage' ? props.data.value.background.value : null;
+ if (imageId !== null) {
+ const match = props.data.value.userImages.find((i) => i.id === imageId.id);
+ if (match) {
+ return match;
+ }
+ }
+ return null;
+ });
+
+ const firstImage = useComputed(() => {
+ return props.data.value.userImages[0] ?? null;
+ });
+
+ // prettier-ignore
+ const label = empty.value === true
+ ? Add Background
+ : My Backgrounds;
+
+ if (empty.value === true) {
+ return (
+
+
+ {label}
+
+ );
+ }
+
+ // prettier-ignore
+ const image = selectedImage.value !== null
+ ? selectedImage.value?.thumb
+ : firstImage.value?.thumb;
+
+ // prettier-ignore
+ const scheme = selectedImage.value !== null
+ ? selectedImage.value?.colorScheme
+ : firstImage.value?.colorScheme;
+
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js
new file mode 100644
index 000000000..52b61ec5d
--- /dev/null
+++ b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js
@@ -0,0 +1,59 @@
+import styles from './CustomizerDrawerInner.module.css';
+import cn from 'classnames';
+import { h } from 'preact';
+import { useComputed } from '@preact/signals';
+
+/**
+ * @param {object} props
+ * @param {import('@preact/signals').Signal} props.data
+ * @param {(theme: import('../../../types/new-tab').ThemeData) => void} props.setTheme
+ */
+export function BrowserThemeSection(props) {
+ const current = useComputed(() => props.data.value.theme);
+ return (
+
+
Browser Theme
+
+ -
+
+ Light
+
+ -
+
+ Dark
+
+ -
+
+ System
+
+
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/customizer/components/ColorSelection.js b/special-pages/pages/new-tab/app/customizer/components/ColorSelection.js
new file mode 100644
index 000000000..0f40712eb
--- /dev/null
+++ b/special-pages/pages/new-tab/app/customizer/components/ColorSelection.js
@@ -0,0 +1,148 @@
+import { h, Fragment } from 'preact';
+import cn from 'classnames';
+
+import { values } from '../values.js';
+import styles from './CustomizerDrawerInner.module.css';
+import { BackChevron, Picker } from '../../components/Icons.js';
+import { useComputed } from '@preact/signals';
+import { detectThemeFromHex } from '../utils.js';
+
+/**
+ * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, PredefinedColor, BackgroundData } from '../../../types/new-tab.js'
+ */
+
+/**
+ * @param {object} props
+ * @param {import("@preact/signals").Signal} props.data
+ * @param {(bg: BackgroundData) => void} props.select
+ * @param {() => void} props.back
+ */
+export function ColorSelection({ data, select, back }) {
+ console.log(' RENDER:ColorSelection?');
+
+ function onClick(event) {
+ let target = /** @type {HTMLElement|null} */ (event.target);
+ while (target && target !== event.currentTarget) {
+ if (target.getAttribute('role') === 'radio') {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (target.getAttribute('aria-checked') === 'false') {
+ if (target.dataset.key) {
+ const value = /** @type {PredefinedColor} */ (target.dataset.key);
+ select({ background: { kind: 'color', value } });
+ } else {
+ console.warn('missing dataset.key');
+ }
+ } else {
+ console.log('ignoring click on selected color');
+ }
+ break;
+ } else {
+ target = target.parentElement;
+ }
+ }
+ }
+
+ return (
+
+ );
+}
+
+const entries = Object.entries(values.colors);
+
+/**
+ * @param {object} props
+ * @param {import("@preact/signals").Signal} props.data
+ */
+function ColorGrid({ data }) {
+ const selected = useComputed(() => data.value.background.kind === 'color' && data.value.background.value);
+ return (
+
+ {entries.map(([key, entry]) => {
+ return (
+
+
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * @param {object} props
+ * @param {import("@preact/signals").Signal} props.data
+ * @param {(bg: BackgroundData) => void} props.select
+ */
+function PickerPanel({ data, select }) {
+ const hex = useComputed(() => {
+ // first case, the user has their background set to be a hex value
+ if (data.value.background.kind === 'hex') {
+ return data.value.background.value;
+ }
+
+ // second case - the user previously set a hex value
+ if (data.value.userColor?.kind === 'hex') {
+ return data.value.userColor.value;
+ }
+
+ // 3rd case - the default
+ return '#ffffff';
+ });
+
+ const hexSelected = useComputed(() => data.value.background.kind === 'hex');
+ const modeSelected = useComputed(() => detectThemeFromHex(hex.value));
+
+ return (
+
+
+
{
+ if (!(e.target instanceof HTMLInputElement)) return;
+ select({ background: { kind: 'hex', value: e.target.value } });
+ }}
+ onClick={(e) => {
+ if (!(e.target instanceof HTMLInputElement)) return;
+ if (data.value.userColor?.value === hex.value) {
+ select({ background: { kind: 'hex', value: e.target.value } });
+ }
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js b/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js
index 16eccf1a6..418ff72c1 100644
--- a/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js
+++ b/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js
@@ -2,10 +2,66 @@ import { h, Fragment } from 'preact';
import { noop } from '../../utils.js';
import { CustomizerButton } from './Customizer.js';
import { VisibilityMenu } from './VisibilityMenu.js';
+import { BackgroundSection } from './BackgroundSection.js';
+import { ColorSelection } from './ColorSelection.js';
+import { GradientSelection } from './GradientSelection.js';
+import { useSignal } from '@preact/signals';
+import { ImageSelection } from './ImageSelection.js';
/** @type {Record import("preact").ComponentChild}>} */
-
export const customizerExamples = {
+ 'customizer.backgroundSection': {
+ factory: () => {
+ return (
+
+ {({ data, select }) => {
+ return ;
+ }}
+
+ );
+ },
+ },
+ 'customizer.colorSelection': {
+ factory: () => {
+ return (
+
+ {({ data, select }) => {
+ return ;
+ }}
+
+ );
+ },
+ },
+ 'customizer.gradientSelection': {
+ factory: () => {
+ return (
+
+ {({ data, select }) => {
+ return ;
+ }}
+
+ );
+ },
+ },
+ 'customizer.imageSelection': {
+ factory: () => {
+ return (
+
+ {({ data, select }) => {
+ return (
+
+ );
+ }}
+
+ );
+ },
+ },
'customizer-menu': {
factory: () => (
@@ -43,3 +99,21 @@ export const customizerExamples = {
function MaxContent({ children }) {
return {children}
;
}
+
+function Provider({ children }) {
+ /** @type {import('../../../types/new-tab.js').CustomizerData} */
+ const data = {
+ background: { kind: 'hex', value: '#17afa8' },
+ theme: 'system',
+ userImages: [],
+ userColor: null,
+ };
+ const dataSignal = useSignal(data);
+ function select(bg) {
+ dataSignal.value = { ...dataSignal.value, background: bg };
+ }
+ function showPicker() {
+ console.log('no-op');
+ }
+ return children({ data: dataSignal, select, showPicker });
+}
diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawer.js b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawer.js
index cfdcad27e..684a9f825 100644
--- a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawer.js
+++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawer.js
@@ -20,6 +20,7 @@ export function CustomizerDrawer({ displayChildren }) {
close();
}
};
+
// check once on page load
checker();
@@ -34,6 +35,6 @@ export function CustomizerDrawer({ displayChildren }) {
}
function CustomizerConsumer() {
- const { data } = useContext(CustomizerContext);
- return ;
+ const { data, select, upload, setTheme, deleteImage } = useContext(CustomizerContext);
+ return ;
}
diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js
index 5a2d2fae2..c981cecc7 100644
--- a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js
+++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js
@@ -1,43 +1,49 @@
import { h } from 'preact';
import styles from './CustomizerDrawerInner.module.css';
-import { useState, useEffect } from 'preact/hooks';
-import { Customizer, getItems } from './Customizer';
-import { VisibilityMenu } from './VisibilityMenu.js';
import { useDrawerControls } from '../../components/Drawer.js';
+import { BackgroundSection } from './BackgroundSection.js';
+import { BrowserThemeSection } from './BrowserThemeSection.js';
+import { VisibilityMenuSection } from './VisibilityMenuSection.js';
+import { ColorSelection } from './ColorSelection.js';
+import { GradientSelection } from './GradientSelection.js';
+import { useSignal } from '@preact/signals';
+import { ImageSelection } from './ImageSelection.js';
/**
- * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData } from '../../../types/new-tab.js'
+ * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData } from '../../../types/new-tab.js'
*/
/**
* @param {object} props
- * @param {import("@preact/signals").Signal} props.data
+ * @param {import('@preact/signals').Signal} props.data
+ * @param {(bg: BackgroundData) => void} props.select
+ * @param {() => void} props.onUpload
+ * @param {(theme: import('../../../types/new-tab').ThemeData) => void} props.setTheme
+ * @param {(id: string) => void} props.deleteImage
*/
-export function CustomizerDrawerInner({ data }) {
+export function CustomizerDrawerInner({ data, select, onUpload, setTheme, deleteImage }) {
const { close } = useDrawerControls();
- const [rowData, setRowData] = useState(() => {
- const items = /** @type {import("./Customizer.js").VisibilityRowData[]} */ (getItems());
- return items;
- });
-
- useEffect(() => {
- function handler() {
- setRowData(getItems());
- }
- window.addEventListener(Customizer.UPDATE_EVENT, handler);
- return () => {
- window.removeEventListener(Customizer.UPDATE_EVENT, handler);
- };
- }, []);
-
+ const state = useSignal('home');
+ function onNav(nav) {
+ state.value = nav;
+ }
+ function back() {
+ state.value = 'home';
+ }
return (
-
+
-
-
+ {state.value === 'home' && }
+ {state.value === 'home' && }
+ {state.value === 'home' && }
+ {state.value === 'color' && }
+ {state.value === 'gradient' && }
+ {state.value === 'image' && (
+
+ )}
);
}
diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css
index 028c29c61..2a0b116c3 100644
--- a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css
+++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css
@@ -4,6 +4,153 @@
animation-timing-function: ease-in-out;
animation-duration: .1s;
padding-block: 1rem;
+ display: grid;
+ grid-auto-rows: max-content;
+ grid-row-gap: var(--sp-4);
+ font-size: var(--small-label-font-size);
+ line-height: var(--small-label-line-height);
+ font-weight: var(--small-label-font-weight);
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+}
+
+.backBtn {
+ background: none;
+ border: none;
+ outline: none;
+ display: flex;
+ padding: 0;
+ align-items: center;
+ gap: 4px;
+ color: inherit;
+
+ svg {
+ width: 16px; height: 16px; display: block
+ }
+
+ &:active {
+ opacity: .8;
+ }
+ &:focus-visible {
+ outline: 1px solid var(--ntp-focus-outline-color)
+ }
+}
+.section {
+ width: 100%;
+}
+.sectionBody {
+ margin-top: 16px;
+}
+.sectionTitle {
+ font-size: var(--title-3-em-font-size);
+ font-weight: var(--title-3-em-font-weight);
+ line-height: var(--title-3-em-line-height);
+}
+.bgList {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: max-content max-content;
+ grid-gap: 12px;
+}
+.bgListItem {
+ display: grid;
+ grid-row-gap: 6px;
+ white-space: nowrap;
+ position: relative;
+
+ &:hover {
+ .deleteBtn {
+ opacity: 1
+ }
+ }
+}
+.bgPanel {
+ display: grid;
+ aspect-ratio: 16/10;
+ border-radius: 4px;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ outline: none;
+
+ &[aria-checked="true"] {
+ outline: 3px solid var(--ntp-color-primary);
+ outline-offset: 2px;
+ }
+ &:focus-visible {
+ outline: 3px solid var(--ntp-focus-outline-color);
+ outline-offset: 2px;
+ }
+ &:active {
+ opacity: .9;
+ }
+}
+
+.bgPanelEmpty {
+ border: 1px solid var(--ntp-surface-border-color);
+ background-color: rgba(0, 0, 0, 0.03);
+ [data-theme=dark] & {
+ background-color: rgba(255, 255, 255, 0.06);
+ }
+}
+
+.bgPanelOutlined {
+ border: 1px solid var(--ntp-surface-border-color);
+ background-color: transparent;
+}
+.dynamicIconColor {
+ &[data-color-mode="light"] {
+ color: black;
+ svg path {
+ fill-opacity: 0.84;
+ }
+ }
+ &[data-color-mode="dark"] {
+ color: white;
+ svg path {
+ fill-opacity: 0.84;
+ }
+ }
+}
+.colorInputIcon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translateX(-50%) translateY(-50%);
+ &, svg {
+ pointer-events: none;
+ }
+}
+.themeList {
+
+ display: flex;
+ gap: 18px;
+}
+.themeItem {
+ display: grid;
+ justify-items: center;
+ grid-row-gap: 6px;
+}
+.themeButton {
+ display: block;
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ border: 1px solid #0000001F;
+ &[aria-checked="true"] {
+ outline: 3px solid var(--ntp-color-primary);
+ outline-offset: 2px;
+ }
+ &:focus-visible {
+ outline: 3px solid var(--ntp-focus-outline-color);
+ outline-offset: 2px;
+ }
+ &:active {
+ opacity: .9;
+ }
}
@keyframes fade-in {
@@ -17,3 +164,10 @@
visibility: visible;
}
}
+
+.deleteBtn {
+ opacity: 0;
+ position: absolute;
+ top: 4px;
+ right: 4px;
+}
diff --git a/special-pages/pages/new-tab/app/customizer/components/GradientSelection.js b/special-pages/pages/new-tab/app/customizer/components/GradientSelection.js
new file mode 100644
index 000000000..410e26c7c
--- /dev/null
+++ b/special-pages/pages/new-tab/app/customizer/components/GradientSelection.js
@@ -0,0 +1,90 @@
+import { h } from 'preact';
+import cn from 'classnames';
+
+import { values } from '../values.js';
+import styles from './CustomizerDrawerInner.module.css';
+import { useComputed } from '@preact/signals';
+import { BackChevron } from '../../components/Icons.js';
+
+/**
+ * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, BackgroundData, CustomizerData, PredefinedGradient } from '../../../types/new-tab.js'
+ */
+
+/**
+ * @param {object} props
+ * @param {import('@preact/signals').Signal} props.data
+ * @param {(bg: BackgroundData) => void} props.select
+ * @param {() => void} props.back
+ */
+export function GradientSelection({ data, select, back }) {
+ // const gradient = values.gradients.gradient02;
+
+ function onClick(event) {
+ let target = /** @type {HTMLElement|null} */ (event.target);
+ while (target && target !== event.currentTarget) {
+ if (target.getAttribute('role') === 'radio') {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (target.getAttribute('aria-checked') === 'false') {
+ if (target.dataset.key) {
+ const value = /** @type {PredefinedGradient} */ (target.dataset.key);
+ select({ background: { kind: 'gradient', value } });
+ } else {
+ console.warn('missing dataset.key');
+ }
+ } else {
+ console.log('ignoring click on selected color');
+ }
+ break;
+ } else {
+ target = target.parentElement;
+ }
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+const entries = Object.entries(values.gradients);
+/**
+ * @param {object} props
+ * @param {import("@preact/signals").Signal} props.data
+ */
+function GradientGrid({ data }) {
+ const selected = useComputed(() => data.value.background.kind === 'gradient' && data.value.background.value);
+ return (
+
+ {entries.map(([key, entry]) => {
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js b/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js
new file mode 100644
index 000000000..dd8fe8ede
--- /dev/null
+++ b/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js
@@ -0,0 +1,103 @@
+import { h } from 'preact';
+import cn from 'classnames';
+
+import styles from './CustomizerDrawerInner.module.css';
+import { useComputed } from '@preact/signals';
+import { DismissButton } from '../../components/DismissButton.jsx';
+import { BackChevron } from '../../components/Icons.js';
+
+/**
+ * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData, PredefinedGradient } from '../../../types/new-tab.js'
+ */
+
+/**
+ * @param {object} props
+ * @param {import('@preact/signals').Signal} props.data
+ * @param {(bg: BackgroundData) => void} props.select
+ * @param {() => void} props.back
+ * @param {() => void} props.onUpload
+ * @param {(id: string) => void} props.deleteImage
+ */
+export function ImageSelection({ data, select, back, onUpload, deleteImage }) {
+ // const gradient = values.gradients.gradient02;
+
+ function onClick(event) {
+ let target = /** @type {HTMLElement|null} */ (event.target);
+ while (target && target !== event.currentTarget) {
+ if (target.getAttribute('role') === 'radio') {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (target.getAttribute('aria-checked') === 'false') {
+ if (target.dataset.key) {
+ const value = /** @type {string} */ (target.dataset.key);
+ const match = data.value.userImages.find((i) => i.id === value);
+ if (match) {
+ select({ background: { kind: 'userImage', value: match } });
+ }
+ } else {
+ console.warn('missing dataset.key');
+ }
+ } else {
+ console.log('ignoring click on selected color');
+ }
+ break;
+ } else {
+ target = target.parentElement;
+ }
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+/**
+ * @param {object} props
+ * @param {import("@preact/signals").Signal} props.data
+ * @param {(id: string) => void} props.deleteImage
+ * @param {() => void} props.onUpload
+ */
+function ImageGrid({ data, deleteImage, onUpload }) {
+ const selected = useComputed(() => data.value.background.kind === 'userImage' && data.value.background.value.id);
+ const entries = useComputed(() => {
+ return data.value.userImages;
+ });
+ return (
+
+ {entries.value.map((entry) => {
+ return (
+ -
+
+ deleteImage(entry.id)} />
+
+ );
+ })}
+ -
+
+
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.js b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.js
index 938e11cb5..8a7c374aa 100644
--- a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.js
+++ b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.js
@@ -1,4 +1,5 @@
import { h } from 'preact';
+import cn from 'classnames';
import { useId } from 'preact/hooks';
import { DuckFoot, Shield } from '../../components/Icons.js';
@@ -22,7 +23,7 @@ export function VisibilityMenu({ rows, variant = 'popover' }) {
const MENU_ID = useId();
return (
-
+
{rows.map((row) => {
return (
-
diff --git a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.module.css b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.module.css
index f5d5f8b66..df78c99b9 100644
--- a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.module.css
+++ b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.module.css
@@ -7,7 +7,7 @@
border: 1px solid var(--color-black-at-9);
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.20), 0px 2px 4px 0px rgba(0, 0, 0, 0.15);
- @media screen and (prefers-color-scheme: dark) {
+ [data-theme=dark] & {
border-color: var(--color-white-at-9);
box-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.09) inset, 0px 0px 0px 1px rgba(0, 0, 0, 0.50), 0px 2px 4px 0px rgba(0, 0, 0, 0.15), 0px 8px 16px 0px rgba(0, 0, 0, 0.40);
}
@@ -20,6 +20,11 @@
display: flex;
flex-direction: column;
gap: var(--sp-1);
+ font-size: var(--title-3-em-font-size);
+}
+
+.embedded {
+ font-size: var(--small-label-font-size);
}
.menuItemLabel {
@@ -27,7 +32,6 @@
align-items: center;
gap: 10px;
white-space: nowrap;
- font-size: var(--title-3-em-font-size);
height: calc(28 * var(--px-in-rem));
> * {
@@ -63,21 +67,21 @@
border-radius: var(--border-radius-xs);
border: 1px solid var(--color-black-at-48);
- @media screen and (prefers-color-scheme: dark) {
+ [data-theme=dark] & {
border-color: rgba(255, 255, 255, 0.42);
background: rgba(255, 255, 255, 0.12);
}
&:hover {
background: linear-gradient(0deg, var(--color-black-at-6) 0%, var(--color-black-at-6) 100%);
- @media screen and (prefers-color-scheme: dark) {
+ [data-theme=dark] & {
background: linear-gradient(0deg, var(--color-white-at-18) 0%, var(--color-white-at-18) 100%), var(--color-white-at-12);
}
}
&:active {
background: linear-gradient(0deg, var(--color-black-at-12) 0%, var(--color-black-at-12) 100%), var(--color-white-at-60);
- @media screen and (prefers-color-scheme: dark) {
+ [data-theme=dark] & {
background: linear-gradient(0deg, var(--color-white-at-24) 0%, var(--color-white-at-24) 100%), var(--color-white-at-12);
}
}
@@ -87,7 +91,7 @@
background: var(--color-blue-50);
border-color: var(--color-blue-50);
- @media screen and (prefers-color-scheme: dark) {
+ [data-theme=dark] & {
background: var(--color-blue-20);
border-color: var(--color-blue-20);
}
@@ -95,7 +99,7 @@
&:hover {
background: var(--color-blue-60);
border-color: var(--color-blue-60);
- @media screen and (prefers-color-scheme: dark) {
+ [data-theme=dark] & {
background: var(--color-blue-30);
border-color: var(--color-blue-30);
}
@@ -105,7 +109,7 @@
background: var(--color-blue-70);
border-color: var(--color-blue-70);
- @media screen and (prefers-color-scheme: dark) {
+ [data-theme=dark] & {
background: var(--color-blue-40);
border-color: var(--color-blue-40);
}
@@ -120,7 +124,7 @@
.menuItemLabel input:checked + .checkboxIcon svg path {
stroke: white;
opacity: 1;
- @media screen and (prefers-color-scheme: dark) {
+ [data-theme=dark] & {
stroke: black;
}
}
diff --git a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js
new file mode 100644
index 000000000..79e88fed7
--- /dev/null
+++ b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js
@@ -0,0 +1,29 @@
+import { useLayoutEffect, useState } from 'preact/hooks';
+import { Customizer, getItems } from './Customizer.js';
+import styles from './CustomizerDrawerInner.module.css';
+import { VisibilityMenu } from './VisibilityMenu.js';
+import { h } from 'preact';
+
+export function VisibilityMenuSection() {
+ const [rowData, setRowData] = useState(() => {
+ const items = /** @type {import("./Customizer.js").VisibilityRowData[]} */ (getItems());
+ return items;
+ });
+ useLayoutEffect(() => {
+ function handler() {
+ setRowData(getItems());
+ }
+ window.addEventListener(Customizer.UPDATE_EVENT, handler);
+ return () => {
+ window.removeEventListener(Customizer.UPDATE_EVENT, handler);
+ };
+ }, []);
+ return (
+
+ );
+}
diff --git a/special-pages/pages/new-tab/app/customizer/customizer.md b/special-pages/pages/new-tab/app/customizer/customizer.md
index 88f71c7ba..6d6c7fce4 100644
--- a/special-pages/pages/new-tab/app/customizer/customizer.md
+++ b/special-pages/pages/new-tab/app/customizer/customizer.md
@@ -22,13 +22,138 @@ title: Customizer
],
"widgetConfigs": [
{ "id": "favorites", "visibility": "visible" },
- { "id": "privacyStats", "visibility": "visible" },
+ { "id": "privacyStats", "visibility": "visible" }
],
"settings": {
"customizerDrawer": {
"state": "enabled"
}
+ },
+ "customizer": {
+ "userImages": [],
+ "userColor": null,
+ "theme": "dark",
+ "background": { "kind": "default" }
}
}
```
+## Initial Data
+
+- Add the key `customizer` to `initialSetup`
+- The data takes the following form: {@link "NewTab Messages".CustomizerData}
+- Example from `initialSetup`
+
+```json
+{
+ "...": "...",
+ "customizer": {
+ "userImages": [],
+ "userColor": null,
+ "theme": "dark",
+ "background": { "kind": "default" }
+ }
+}
+```
+
+## Subscriptions
+
+- {@link "NewTab Messages".CustomizerOnBackgroundUpdateSubscription `customizer_onBackgroundUpdate`}.
+ - Sends {@link "NewTab Messages".BackgroundData} whenever needed.
+ - For example:
+ - ```json
+ {
+ "background": { "kind": "color", "value": "color01" }
+ }
+ ```
+ - ```json
+ {
+ "background": { "kind": "gradient", "value": "gradient01" }
+ }
+ ```
+ - ```json
+ {
+ "background": { "kind": "hex", "value": "#cacaca" }
+ }
+ ```
+ - ```json
+ {
+ "background": { "kind": "default" }
+ }
+ ```
+ - ```json
+ {
+ "background": {
+ "kind": "userImage",
+ "value": { "id": "abc", "src": "...", "thumb": "...", "colorScheme": "light" }
+ }
+ }
+ ```
+
+- {@link "NewTab Messages".CustomizerOnImagesUpdateSubscription `customizer_onImagesUpdate`}.
+ - Sends {@link "NewTab Messages".UserImageData} whenever needed.
+ - For example, this would be pushed into the page following a successful upload
+ - Note: In that situation, you'd send this followed by `customizer_onBackgroundUpdate` above
+ - For example:
+ - ```json
+ {
+ "userImages": [{"id": "abc", "src": "...", "thumb": "...", "colorScheme": "light" }]
+ }
+ ```
+
+- {@link "NewTab Messages".CustomizerOnColorUpdateSubscription `customizer_onColorUpdate`}.
+ - Sends {@link "NewTab Messages".UserColorData} whenever needed.
+ - For example:
+ - ```json
+ {
+ "userColor": { "kind": "hex", "value": "#cacaca" }
+ }
+ ```
+ or:
+ - ```json
+ {
+ "userColor": null
+ }
+ ```
+
+- {@link "NewTab Messages".CustomizerOnThemeUpdateSubscription `customizer_onThemeUpdate`}.
+ - Sends {@link "NewTab Messages".ThemeData} whenever needed.
+ - For example:
+ - ```json
+ {
+ "theme": "system"
+ }
+ ```
+
+## Notifications
+
+- {@link "NewTab Messages".CustomizerSetBackgroundNotification `customizer_setBackground`}.
+ - Sends {@link "NewTab Messages".CustomizerSetBackgroundNotify} whenever needed.
+ - For example:
+ - ```json
+ {
+ "background": { "kind": "color", "value": "color01" }
+ }
+ ```
+
+- {@link "NewTab Messages".CustomizerSetThemeNotification `customizer_setTheme`}.
+ - Sends {@link "NewTab Messages".CustomizerSetBackgroundNotify} whenever needed.
+ - For example:
+ - ```json
+ {
+ "theme": "light"
+ }
+ ```
+
+- {@link "NewTab Messages".CustomizerUploadNotification `customizer_upload`}.
+ - Sent to trigger a file upload
+
+
+- {@link "NewTab Messages".CustomizerDeleteImageNotification `customizer_deleteImage`}.
+ - Sends {@link "NewTab Messages".CustomizerDeleteImageNotify} whenever needed.
+ - For example:
+ - ```json
+ {
+ "id": "abc"
+ }
+ ```
diff --git a/special-pages/pages/new-tab/app/customizer/customizer.service.js b/special-pages/pages/new-tab/app/customizer/customizer.service.js
index 2a1d76970..05a5324dc 100644
--- a/special-pages/pages/new-tab/app/customizer/customizer.service.js
+++ b/special-pages/pages/new-tab/app/customizer/customizer.service.js
@@ -61,4 +61,78 @@ export class CustomizerService {
this.imagesService.destroy();
this.colorService.destroy();
}
+
+ /**
+ * @param {(evt: {data: BackgroundData, source: 'manual' | 'subscription'}) => void} cb
+ * @internal
+ */
+ onBackground(cb) {
+ return this.bgService.onData(cb);
+ }
+ /**
+ * @param {(evt: {data: ThemeData, source: 'manual' | 'subscription'}) => void} cb
+ * @internal
+ */
+ onTheme(cb) {
+ return this.themeService.onData(cb);
+ }
+ /**
+ * @param {(evt: {data: UserImageData, source: 'manual' | 'subscription'}) => void} cb
+ * @internal
+ */
+ onImages(cb) {
+ return this.imagesService.onData(cb);
+ }
+ /**
+ * @param {(evt: {data: UserColorData, source: 'manual' | 'subscription'}) => void} cb
+ * @internal
+ */
+ onColor(cb) {
+ return this.colorService.onData(cb);
+ }
+
+ /**
+ * @param {BackgroundData} bg
+ */
+ setBackground(bg) {
+ this.bgService.update((data) => {
+ return bg;
+ });
+ if (bg.background.kind === 'hex') {
+ this.colorService.update((_old) => {
+ if (bg.background.kind !== 'hex') throw new Error('unreachable code path');
+ return { userColor: structuredClone(bg.background) };
+ });
+ }
+ }
+
+ /**
+ * @param {string} id
+ */
+ deleteImage(id) {
+ this.imagesService.update((data) => {
+ return {
+ ...data,
+ userImages: data.userImages.filter((img) => img.id !== id),
+ };
+ });
+ this.ntp.messaging.notify('customizer_deleteImage', { id });
+ }
+
+ /**
+ *
+ */
+ upload() {
+ this.ntp.messaging.notify('customizer_upload');
+ }
+
+ /**
+ * @param {ThemeData} theme
+ */
+ setTheme(theme) {
+ this.themeService.update((_data) => {
+ return theme;
+ });
+ this.ntp.messaging.notify('customizer_setTheme', theme);
+ }
}
diff --git a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js
index 00328e7c8..09d2374b8 100644
--- a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js
+++ b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js
@@ -1,4 +1,5 @@
-import { expect } from '@playwright/test';
+import { test, expect } from '@playwright/test';
+import { values } from '../values.js';
/**
* @typedef {import('../../../types/new-tab.js').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionEventNames
@@ -12,6 +13,95 @@ export class CustomizerPage {
this.ntp = ntp;
}
+ async showsColorSelectionPanel() {
+ const { page } = this.ntp;
+ await page.locator('aside').getByLabel('Solid Colors').click();
+ }
+
+ async opensCustomizer() {
+ const { page } = this.ntp;
+ await page.getByRole('button', { name: 'Customize' }).click();
+ }
+
+ async hasDefaultBackgroundSelected() {
+ const { page } = this.ntp;
+ const selected = page.locator('aside').getByLabel('Default');
+ await expect(selected).toHaveAttribute('aria-checked', 'true');
+ }
+
+ /**
+ * @param {'light' | 'dark'} theme
+ */
+ async mainContentHasTheme(theme) {
+ const { page } = this.ntp;
+ await test.step(`main content area theme should be: ${theme}`, async () => {
+ await expect(page.locator('main')).toHaveAttribute('data-theme', theme);
+ });
+ }
+
+ /**
+ * @param {'light' | 'dark'} theme
+ */
+ async drawerHasTheme(theme) {
+ const { page } = this.ntp;
+ await test.step(`customizer drawer theme should be: ${theme}`, async () => {
+ await expect(page.locator('aside')).toHaveAttribute('data-theme', theme);
+ });
+ }
+
+ async hasColorSelected() {
+ const { page } = this.ntp;
+ const selected = page.locator('aside').getByLabel('Solid Colors');
+ await expect(selected).toHaveAttribute('aria-checked', 'true');
+ }
+
+ async hasGradientSelected() {
+ const { page } = this.ntp;
+ await page.pause();
+ const selected = page.locator('aside').getByLabel('Gradients');
+ await expect(selected).toHaveAttribute('aria-checked', 'true');
+ }
+
+ async hasImagesSelected() {
+ const { page } = this.ntp;
+ const selected = page.locator('aside').getByLabel('My Backgrounds');
+ await expect(selected).toHaveAttribute('aria-checked', 'true');
+ }
+
+ async uploadsFirstImage() {
+ const { page } = this.ntp;
+ await page.getByLabel('Add Background').click();
+ await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_upload' });
+ }
+ async setsDarkTheme() {
+ const { page } = this.ntp;
+ await page.getByRole('radio', { name: 'Select dark theme' }).click();
+ const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setTheme' });
+ expect(calls[0].payload).toMatchObject({
+ method: 'customizer_setTheme',
+ params: { theme: 'dark' },
+ });
+ }
+
+ async lightThemeIsSelected() {
+ const { page } = this.ntp;
+ await expect(page.getByRole('radio', { name: 'Select light theme' })).toHaveAttribute('aria-checked', 'true');
+ }
+ async darkThemeIsSelected() {
+ const { page } = this.ntp;
+ await expect(page.getByRole('radio', { name: 'Select dark theme' })).toHaveAttribute('aria-checked', 'true');
+ }
+
+ async selectsDefault() {
+ const { page } = this.ntp;
+ await page.locator('aside').getByLabel('Default').click();
+ const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
+ expect(calls[0].payload).toMatchObject({
+ method: 'customizer_setBackground',
+ params: { background: { kind: 'default' } },
+ });
+ }
+
async hasDefaultBackground() {
const { page } = this.ntp;
await expect(page.getByTestId('BackgroundConsumer')).toHaveCSS('background-color', 'rgb(250, 250, 250)');
@@ -21,4 +111,149 @@ export class CustomizerPage {
const { page } = this.ntp;
await expect(page.getByTestId('BackgroundConsumer')).toHaveCSS('background-color', 'rgb(51, 51, 51)');
}
+
+ /**
+ * @param {keyof typeof values.colors} color
+ */
+ async hasColorBackground(color) {
+ const { page } = this.ntp;
+ const value = values.colors[color];
+ await expect(page.getByTestId('BackgroundConsumer')).toHaveAttribute('data-background-color', value.hex);
+ }
+
+ async selectsColor() {
+ const { page } = this.ntp;
+ await this.showsColorSelectionPanel();
+ await page.getByRole('radio', { name: 'Select color03' }).click();
+ const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
+ expect(calls[0].payload).toMatchObject({
+ method: 'customizer_setBackground',
+ params: { background: { kind: 'color', value: 'color03' } },
+ });
+ return async () => await page.getByRole('button', { name: 'Solid Colors' }).click();
+ }
+
+ async selectsGradient() {
+ const { page } = this.ntp;
+ await page.locator('aside').getByLabel('Gradients').click();
+ await page.getByRole('radio', { name: 'Select gradient01' }).click();
+ const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
+ expect(calls[0].payload).toMatchObject({
+ method: 'customizer_setBackground',
+ params: { background: { kind: 'gradient', value: 'gradient01' } },
+ });
+ return async () => await page.getByRole('button', { name: 'Gradients' }).click();
+ }
+
+ /**
+ * @param {import('../../../types/new-tab.js').BackgroundVariant} bg
+ */
+ async acceptsBackgroundUpdate(bg) {
+ /** @type {import('../../../types/new-tab.js').BackgroundData} */
+ const payload = { background: bg };
+ /** @type {SubscriptionEventNames} */
+ const named = 'customizer_onBackgroundUpdate';
+ await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
+ }
+
+ /**
+ * @param {'light' | 'dark'} theme
+ */
+ async acceptsThemeUpdate(theme) {
+ /** @type {import('../../../types/new-tab.js').ThemeData} */
+ const payload = { theme };
+ /** @type {SubscriptionEventNames} */
+ const named = 'customizer_onThemeUpdate';
+ await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
+ }
+
+ /**
+ * @param {string} color
+ */
+ async acceptsColorUpdate(color) {
+ await test.step('subscription event: customizer_onColorUpdate', async () => {
+ /** @type {import('../../../types/new-tab.js').UserColorData} */
+ const payload = { userColor: { kind: 'hex', value: color } };
+ /** @type {SubscriptionEventNames} */
+ const named = 'customizer_onColorUpdate';
+ await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
+ });
+ }
+
+ /**
+ *
+ */
+ async acceptsImagesUpdate() {
+ const { page } = this.ntp;
+ await test.step('subscription event: customizer_onImagesUpdate', async () => {
+ // Listener for the thumb loading
+ const resPromise = page.waitForResponse((req) => {
+ return req.url().includes(values.userImages['01'].thumb);
+ });
+
+ /** @type {import('../../../types/new-tab.js').UserImageData} */
+ const payload = { userImages: [values.userImages['01']] };
+ /** @type {SubscriptionEventNames} */
+ const named = 'customizer_onImagesUpdate';
+ await this.ntp.mocks.simulateSubscriptionMessage(named, payload);
+
+ const response = await resPromise;
+ await page.pause();
+ expect(response.ok()).toBe(true);
+ });
+ }
+
+ /**
+ * @param {'light' | 'dark'} theme
+ */
+ async hasContentTheme(theme) {
+ const { page } = this.ntp;
+ await expect(page.getByRole('main')).toHaveAttribute('data-theme', theme);
+ }
+
+ /**
+ * @param {string} color
+ */
+ async selectsCustomColor(color) {
+ const { page } = this.ntp;
+ await page.locator('input[type="color"]').click();
+ await page.waitForTimeout(500);
+ await page.locator('input[type="color"]').fill(color);
+ await page.locator('body').click();
+ }
+
+ /**
+ * @param {string} color
+ */
+ async selectsPreviousCustomColor(color) {
+ const { page } = this.ntp;
+ await this.showsColorSelectionPanel();
+ await expect(page.locator('input[type="color"]')).toHaveValue(color);
+ await page.locator('input[type="color"]').click();
+ await page.locator('body').click();
+ }
+
+ /**
+ * @param {string} color
+ */
+ async hasCustomColorValue(color) {
+ const { page } = this.ntp;
+ await expect(page.locator('input[type="color"]'), { message: `input should have value ${color}` }).toHaveValue(color);
+ }
+
+ /**
+ * @param {string} color
+ */
+ async savesTheCustomColor(color) {
+ const calls = await this.ntp.mocks.waitForCallCount({ count: 1, method: 'customizer_setBackground' });
+ expect(calls[0].payload).toMatchObject({
+ method: 'customizer_setBackground',
+ params: { background: { kind: 'hex', value: color } },
+ });
+ }
+
+ async hasEmptyImagesPanel() {
+ const { page } = this.ntp;
+ await page.getByLabel('Add Background').waitFor();
+ }
}
diff --git a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.spec.js b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.spec.js
index be8c7360d..ec28f9b0a 100644
--- a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.spec.js
+++ b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.spec.js
@@ -17,6 +17,188 @@ test.describe('newtab customizer', () => {
await ntp.darkMode();
await ntp.openPage({ additional: { customizerDrawer: 'disabled' } });
await cp.hasDefaultDarkBackground();
- await page.pause();
+ });
+ test('loads with the default background', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled' } });
+
+ await cp.opensCustomizer();
+ await cp.hasDefaultBackgroundSelected();
+ });
+ test('respects CSS media query for light/dark when browser theme is "system"', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', theme: 'system' } });
+ await cp.opensCustomizer();
+
+ // check the main page + drawer both are light theme
+ await cp.mainContentHasTheme('light');
+ await cp.drawerHasTheme('light');
+
+ // emulate changing os-level settings to 'dark'
+ await ntp.darkMode();
+
+ // now assert the themes updated correctly
+ await cp.mainContentHasTheme('dark');
+ await cp.drawerHasTheme('dark');
+ });
+ test('loads with the default background and accepts background update', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled' } });
+
+ await cp.hasDefaultBackground();
+ await cp.opensCustomizer();
+ await cp.hasDefaultBackgroundSelected();
+
+ await cp.acceptsBackgroundUpdate({
+ kind: 'color',
+ value: 'color01',
+ });
+ await cp.hasColorBackground('color01');
+ });
+ test('loads with the default background and accepts theme update', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled' } });
+
+ await cp.opensCustomizer();
+ await cp.hasDefaultBackgroundSelected();
+
+ // this is a control, to ensure it's light before we deliver an update
+ await cp.hasContentTheme('light');
+
+ // now deliver the update and ensure it changed
+ await cp.acceptsThemeUpdate('dark');
+ await cp.hasContentTheme('dark');
+ });
+ test('loads with a color background', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', background: 'color01', theme: 'dark' } });
+ await cp.opensCustomizer();
+ await cp.hasColorSelected();
+ await cp.acceptsThemeUpdate('light');
+ });
+ test('loads with a color background, and sets back to default', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', background: 'color01' } });
+ await cp.opensCustomizer();
+ await cp.hasColorSelected();
+ await cp.selectsDefault();
+ });
+ test('loads with default background, and sets a color', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled' } });
+ await cp.opensCustomizer();
+ await cp.hasDefaultBackgroundSelected();
+ const back = await cp.selectsColor();
+ await back();
+ await cp.hasColorSelected();
+ });
+ test('loads with default background, and uses color picker', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', background: 'default' } });
+ await cp.opensCustomizer();
+ await cp.hasDefaultBackgroundSelected();
+ await cp.showsColorSelectionPanel();
+
+ await cp.selectsCustomColor('#cacaca');
+ await cp.savesTheCustomColor('#cacaca');
+ });
+ test('loads with default background and accepts a color update', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', background: 'default' } });
+ await cp.opensCustomizer();
+ await cp.hasDefaultBackgroundSelected();
+ await cp.showsColorSelectionPanel();
+
+ await test.step('when a color update is received', async () => {
+ await cp.hasCustomColorValue('#ffffff');
+ await cp.acceptsColorUpdate('#cacaca');
+ });
+
+ await test.step('then the custom color panel should reflect the color', async () => {
+ await cp.hasCustomColorValue('#cacaca');
+ });
+ });
+ test('switches from selected predefined color, to a previously selected hex value', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', background: 'color01', userColor: 'cacaca' } });
+ await cp.opensCustomizer();
+ await cp.hasColorSelected();
+ await cp.selectsPreviousCustomColor('#cacaca');
+ await cp.savesTheCustomColor('#cacaca');
+ });
+ test('loads with default background, and sets a gradient', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled' } });
+ await cp.opensCustomizer();
+ await cp.hasDefaultBackgroundSelected();
+ const back = await cp.selectsGradient();
+ await back();
+ await cp.hasGradientSelected();
+ });
+ test('loads with a gradient background', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', background: 'gradient02' } });
+ await cp.opensCustomizer();
+ await cp.hasGradientSelected();
+ });
+ test('loads with a user image', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', background: 'userImage:01', userImages: 'true' } });
+ await cp.opensCustomizer();
+ await cp.hasImagesSelected();
+ });
+ test('loads without images, and then accepts 1', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled' } });
+ await cp.opensCustomizer();
+ await cp.hasEmptyImagesPanel();
+ await cp.acceptsImagesUpdate();
+ });
+ test('trigger file upload', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled' } });
+ await cp.opensCustomizer();
+ await cp.uploadsFirstImage();
+ });
+ test('Sets theme', async ({ page }, workerInfo) => {
+ const ntp = NewtabPage.create(page, workerInfo);
+ const cp = new CustomizerPage(ntp);
+ await ntp.reducedMotion();
+ await ntp.openPage({ additional: { customizerDrawer: 'enabled', theme: 'light' } });
+ await cp.opensCustomizer();
+ await cp.lightThemeIsSelected();
+ await cp.setsDarkTheme();
+ await cp.darkThemeIsSelected();
});
});
diff --git a/special-pages/pages/new-tab/app/customizer/mocks.js b/special-pages/pages/new-tab/app/customizer/mocks.js
new file mode 100644
index 000000000..c0ceb8607
--- /dev/null
+++ b/special-pages/pages/new-tab/app/customizer/mocks.js
@@ -0,0 +1,155 @@
+import { TestTransportConfig } from '@duckduckgo/messaging';
+import { values } from './values.js';
+
+/**
+ * @typedef {import('../../types/new-tab.ts').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionNames
+ * @typedef {import('../../types/new-tab.ts').UserColorData} UserColorData
+ */
+
+const url = new URL(window.location.href);
+
+export function customizerMockTransport({ read, write, broadcast }) {
+ const channel = new BroadcastChannel('ntp_customizer');
+ /** @type {Map} */
+ const subscriptions = new Map();
+
+ /**
+ * @param {SubscriptionNames} named
+ * @param {any} data
+ */
+ function broadcastHere(named, data) {
+ setTimeout(() => {
+ channel.postMessage({
+ subscriptionName: named,
+ params: data,
+ });
+ }, 100);
+ }
+
+ channel.addEventListener('message', (msg) => {
+ if (msg.data.subscriptionName) {
+ const cb = subscriptions.get(msg.data.subscriptionName);
+ if (!cb) return console.warn(`missing subscription for ${msg.data.subscriptionName}`);
+ cb(msg.data.params);
+ }
+ });
+
+ return new TestTransportConfig({
+ notify(_msg) {
+ /** @type {import('../../types/new-tab.ts').NewTabMessages['notifications']} */
+ const msg = /** @type {any} */ (_msg);
+ switch (msg.method) {
+ case 'customizer_setTheme': {
+ broadcastHere('customizer_onThemeUpdate', msg.params);
+ return;
+ }
+ case 'customizer_setBackground': {
+ broadcastHere('customizer_onBackgroundUpdate', msg.params);
+
+ if (msg.params.background.kind === 'hex') {
+ const userColorData = { userColor: msg.params.background };
+ /** @type {UserColorData} */
+ broadcastHere('customizer_onColorUpdate', userColorData);
+ }
+
+ return;
+ }
+ default: {
+ console.warn('unhandled customizer notification', msg);
+ }
+ }
+ },
+ subscribe(_msg, cb) {
+ /** @type {import('../../types/new-tab.ts').NewTabMessages['subscriptions']['subscriptionEvent']} */
+ const sub = /** @type {any} */ (_msg.subscriptionName);
+ switch (sub) {
+ case 'customizer_onColorUpdate':
+ case 'customizer_onThemeUpdate':
+ case 'customizer_onBackgroundUpdate':
+ case 'customizer_onImagesUpdate': {
+ subscriptions.set(sub, cb);
+ console.log('did add sub', sub);
+ return () => {
+ console.log('-- did remove sub', sub);
+ return subscriptions.delete(sub);
+ };
+ }
+ }
+ return () => {};
+ },
+ // eslint-ignore-next-line require-await
+ request(_msg) {
+ /** @type {import('../../types/new-tab.ts').NewTabMessages['requests']} */
+ const msg = /** @type {any} */ (_msg);
+ switch (msg.method) {
+ default: {
+ return Promise.reject(new Error('unhandled request' + msg));
+ }
+ }
+ },
+ });
+}
+
+/** @type {()=>import('../../types/new-tab').CustomizerData} */
+export function customizerData() {
+ /** @type {import('../../types/new-tab').CustomizerData} */
+ const customizer = {
+ userImages: [],
+ userColor: null,
+ theme: 'system',
+ background: { kind: 'default' },
+ };
+
+ if (url.searchParams.has('background')) {
+ const value = url.searchParams.get('background');
+ if (value && value in values.colors) {
+ customizer.background = {
+ kind: 'color',
+ value: /** @type {import('../../types/new-tab').PredefinedColor} */ (value),
+ };
+ } else if (value && value in values.gradients) {
+ customizer.background = {
+ kind: 'gradient',
+ value: /** @type {import('../../types/new-tab').PredefinedGradient} */ (value),
+ };
+ } else if (value && value.startsWith('hex:')) {
+ const hex = value.slice(4);
+ if (hex.length === 6 || hex.length === 8) {
+ customizer.background = {
+ kind: 'hex',
+ value: `#${hex.slice(0, 6)}`,
+ };
+ } else {
+ console.warn('invalid hex values');
+ }
+ } else if (value && value.startsWith('userImage:')) {
+ const image = value.slice(10);
+ if (image in values.userImages) {
+ customizer.background = {
+ kind: 'userImage',
+ value: values.userImages[image],
+ };
+ } else {
+ console.warn('unknown user image');
+ }
+ } else if (value && value === 'default') {
+ customizer.background = { kind: 'default' };
+ }
+ }
+
+ if (url.searchParams.has('userImages')) {
+ customizer.userImages = [values.userImages['01'], values.userImages['02'], values.userImages['03']];
+ }
+ if (url.searchParams.has('userColor')) {
+ const hex = `#` + url.searchParams.get('userColor');
+ customizer.userColor = { kind: 'hex', value: hex };
+ }
+ if (url.searchParams.has('theme')) {
+ const value = url.searchParams.get('theme');
+ if (value === 'light' || value === 'dark' || value === 'system') {
+ customizer.theme = value;
+ }
+ }
+
+ return customizer;
+}
diff --git a/special-pages/pages/new-tab/app/customizer/values.js b/special-pages/pages/new-tab/app/customizer/values.js
new file mode 100644
index 000000000..7e03669f1
--- /dev/null
+++ b/special-pages/pages/new-tab/app/customizer/values.js
@@ -0,0 +1,61 @@
+/**
+ * @import { PredefinedColor, PredefinedGradient, BackgroundColorScheme, UserImage } from "../../types/new-tab"
+ * @type {{
+ * colors: Record,
+ * gradients: Record
+ * userImages: Record<'01' | '02' | '03', UserImage>
+ * }}
+ */
+export const values = {
+ colors: {
+ color01: { hex: '#000000', colorScheme: 'dark' },
+ color02: { hex: '#342E42', colorScheme: 'dark' },
+ color03: { hex: '#4D5F7F', colorScheme: 'dark' },
+ color04: { hex: '#E28499', colorScheme: 'light' },
+ color05: { hex: '#F7DEE5', colorScheme: 'light' },
+ color06: { hex: '#D55154', colorScheme: 'dark' },
+ color07: { hex: '#E5724F', colorScheme: 'dark' },
+ color08: { hex: '#F3BB44', colorScheme: 'light' },
+ color09: { hex: '#E9DCCD', colorScheme: 'light' },
+ color10: { hex: '#5BC787', colorScheme: 'light' },
+ color11: { hex: '#4594A7', colorScheme: 'dark' },
+ color12: { hex: '#B5E2CE', colorScheme: 'light' },
+ color13: { hex: '#E4DEF2', colorScheme: 'light' },
+ color14: { hex: '#B79ED4', colorScheme: 'light' },
+ color15: { hex: '#5552AC', colorScheme: 'dark' },
+ color16: { hex: '#75B9F0', colorScheme: 'light' },
+ color17: { hex: '#577DE4', colorScheme: 'dark' },
+ color18: { hex: '#DBDDDF', colorScheme: 'light' },
+ color19: { hex: '#9A979D', colorScheme: 'dark' },
+ },
+ gradients: {
+ gradient01: { path: 'gradients/gradient01.svg', fallback: '#f2e5d4', colorScheme: 'light' },
+ gradient02: { path: 'gradients/gradient02.svg', fallback: '#d5bcd1', colorScheme: 'light' },
+ gradient03: { path: 'gradients/gradient03.svg', fallback: '#f4ca78', colorScheme: 'light' },
+ gradient04: { path: 'gradients/gradient04.svg', fallback: '#e6a356', colorScheme: 'light' },
+ gradient05: { path: 'gradients/gradient05.svg', fallback: '#4448ae', colorScheme: 'dark' },
+ gradient06: { path: 'gradients/gradient06.svg', fallback: '#a55778', colorScheme: 'dark' },
+ gradient07: { path: 'gradients/gradient07.svg', fallback: '#222566', colorScheme: 'dark' },
+ gradient08: { path: 'gradients/gradient08.svg', fallback: '#0e0e3d', colorScheme: 'dark' },
+ },
+ userImages: {
+ '01': {
+ colorScheme: 'dark',
+ id: '01',
+ src: 'backgrounds/bg-01.jpg',
+ thumb: 'backgrounds/bg-01-thumb.jpg',
+ },
+ '02': {
+ colorScheme: 'light',
+ id: '02',
+ src: 'backgrounds/bg-02.jpg',
+ thumb: 'backgrounds/bg-02-thumb.jpg',
+ },
+ '03': {
+ colorScheme: 'light',
+ id: '03',
+ src: 'backgrounds/bg-03.jpg',
+ thumb: 'backgrounds/bg-03-thumb.jpg',
+ },
+ },
+};
diff --git a/special-pages/pages/new-tab/app/mock-transport.js b/special-pages/pages/new-tab/app/mock-transport.js
index 9fde53e58..8c7fd3e35 100644
--- a/special-pages/pages/new-tab/app/mock-transport.js
+++ b/special-pages/pages/new-tab/app/mock-transport.js
@@ -5,6 +5,7 @@ import { rmfDataExamples } from './remote-messaging-framework/mocks/rmf.data.js'
import { favorites, gen } from './favorites/mocks/favorites.data.js';
import { updateNotificationExamples } from './update-notification/mocks/update-notification.data.js';
import { variants as nextSteps } from './next-steps/nextsteps.data.js';
+import { customizerData, customizerMockTransport } from './customizer/mocks.js';
import { freemiumPIRDataExamples } from './freemium-pir-banner/mocks/freemiumPIRBanner.data.js';
/**
@@ -95,11 +96,20 @@ export function mockTransport() {
}
}
+ const transports = {
+ customizer: customizerMockTransport({ read, write, broadcast }),
+ };
+
return new TestTransportConfig({
notify(_msg) {
window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) });
/** @type {import('../types/new-tab.ts').NewTabMessages['notifications']} */
const msg = /** @type {any} */ (_msg);
+ const [namespace] = msg.method.split('_');
+ if (namespace in transports) {
+ transports[namespace]?.impl.notify(_msg);
+ return;
+ }
switch (msg.method) {
case 'widgets_setConfig': {
if (!msg.params) throw new Error('unreachable');
@@ -184,6 +194,11 @@ export function mockTransport() {
};
}
+ const [namespace] = sub.split('_');
+ if (namespace in transports) {
+ return transports[namespace]?.impl.subscribe(_msg, cb);
+ }
+
switch (sub) {
case 'widgets_onConfigUpdated': {
const controller = new AbortController();
@@ -349,6 +364,12 @@ export function mockTransport() {
window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) });
/** @type {import('../types/new-tab.ts').NewTabMessages['requests']} */
const msg = /** @type {any} */ (_msg);
+
+ const [namespace] = msg.method.split('_');
+ if (namespace in transports) {
+ return transports[namespace]?.impl.request(_msg);
+ }
+
switch (msg.method) {
case 'stats_getData': {
const statsVariant = url.searchParams.get('stats');
@@ -470,24 +491,26 @@ export function mockTransport() {
updateNotification = updateNotificationExamples.populated;
}
- /** @type {import('../types/new-tab').NewTabPageSettings} */
- const settings = {};
-
- if (url.searchParams.get('customizerDrawer') === 'enabled') {
- settings.customizerDrawer = { state: 'enabled' };
- }
-
/** @type {import('../types/new-tab.ts').InitialSetupResponse} */
const initial = {
widgets: widgetsFromStorage,
widgetConfigs: widgetConfigFromStorage,
- settings,
platform: { name: 'integration' },
env: 'development',
locale: 'en',
updateNotification,
};
+ /** @type {import('../types/new-tab').NewTabPageSettings} */
+ const settings = {};
+ if (url.searchParams.get('customizerDrawer') === 'enabled') {
+ settings.customizerDrawer = { state: 'enabled' };
+ initial.customizer = customizerData();
+ }
+
+ // feature flags
+ initial.settings = settings;
+
return Promise.resolve(initial);
}
default: {