From 773f2d369aea3a8260ac74e63ad4675be203f6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 11 May 2026 12:19:23 -0300 Subject: [PATCH 01/26] add presentation field to widget type metadata --- routes/dashboard/widget-dashboard/types.ts | 11 +++++++++++ routes/dashboard/widget-types/types.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/routes/dashboard/widget-dashboard/types.ts b/routes/dashboard/widget-dashboard/types.ts index a3f5913f6d831b..65676e47674c62 100644 --- a/routes/dashboard/widget-dashboard/types.ts +++ b/routes/dashboard/widget-dashboard/types.ts @@ -82,6 +82,17 @@ export interface WidgetTypeMetadata { */ category?: string; + /** + * Authoring intent about how the widget wants to render. Static + * and declarative; not a user-editable attribute. + * + * - `'framed'` (default when absent): the widget renders its + * content only. + * - `'full-bleed'`: the widget renders edge-to-edge with no + * surrounding chrome. + */ + presentation?: 'framed' | 'full-bleed'; + /** * Search aliases used to surface the widget from the inserter. */ diff --git a/routes/dashboard/widget-types/types.ts b/routes/dashboard/widget-types/types.ts index bbec6e2086fa2f..fb12db81a066ee 100644 --- a/routes/dashboard/widget-types/types.ts +++ b/routes/dashboard/widget-types/types.ts @@ -64,6 +64,17 @@ export interface WidgetTypeMetadata { */ category?: string; + /** + * Authoring intent about how the widget wants to render. Static + * and declarative; not a user-editable attribute. + * + * - `'framed'` (default when absent): the widget renders its + * content only. + * - `'full-bleed'`: the widget renders edge-to-edge with no + * surrounding chrome. + */ + presentation?: 'framed' | 'full-bleed'; + /** * Search aliases used to surface the widget from the inserter. */ From 48c77ce58892c2af35f6ae3af3096cc95c83b683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 11 May 2026 12:19:35 -0300 Subject: [PATCH 02/26] expose presentation through php widget registry --- .../class-wp-rest-widget-modules-controller.php | 16 ++++++++++++++++ .../dashboard-widgets/class-wp-widget-type.php | 11 +++++++++++ .../dashboard-widgets/widget-types.php | 1 + 3 files changed, 28 insertions(+) diff --git a/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php b/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php index af0954d9f6388c..852aa8001ddb92 100644 --- a/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php +++ b/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php @@ -182,13 +182,19 @@ public function prepare_item_for_response( $item, $request ) { if ( rest_is_field_included( 'name', $fields ) ) { $data['name'] = $widget_type->name; } + if ( rest_is_field_included( 'render_module', $fields ) ) { $data['render_module'] = $widget_type->render_module; } + if ( rest_is_field_included( 'widget_module', $fields ) ) { $data['widget_module'] = $widget_type->widget_module; } + if ( rest_is_field_included( 'presentation', $fields ) ) { + $data['presentation'] = $widget_type->presentation; + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -217,18 +223,28 @@ public function get_item_schema() { 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), + 'render_module' => array( 'description' => __( 'Script-module handle for the widget render entry point.', 'gutenberg' ), 'type' => array( 'string', 'null' ), 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), + 'widget_module' => array( 'description' => __( 'Script-module handle for the widget metadata entry point.', 'gutenberg' ), 'type' => array( 'string', 'null' ), 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), + + 'presentation' => array( + 'description' => __( 'Authoring intent about how the widget wants to render.', 'gutenberg' ), + 'type' => array( 'string', 'null' ), + 'enum' => array( 'framed', 'full-bleed', null ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), ), ); diff --git a/lib/experimental/dashboard-widgets/class-wp-widget-type.php b/lib/experimental/dashboard-widgets/class-wp-widget-type.php index 142f2dd85955c1..586e75e9f14a61 100644 --- a/lib/experimental/dashboard-widgets/class-wp-widget-type.php +++ b/lib/experimental/dashboard-widgets/class-wp-widget-type.php @@ -49,6 +49,17 @@ class WP_Widget_Type { */ public $widget_module = null; + /** + * Authoring intent about how the widget wants to render. Static + * and declarative; not a user-editable attribute. + * + * One of `'framed'` (default) or `'full-bleed'`. Null when the + * widget did not declare the field. + * + * @var string|null + */ + public $presentation = null; + /** * Constructor. * diff --git a/lib/experimental/dashboard-widgets/widget-types.php b/lib/experimental/dashboard-widgets/widget-types.php index f7dea34843de6c..ad3b50a381923d 100644 --- a/lib/experimental/dashboard-widgets/widget-types.php +++ b/lib/experimental/dashboard-widgets/widget-types.php @@ -41,6 +41,7 @@ function gutenberg_register_widget_types() { array( 'render_module' => $widget['render_module'] ?? null, 'widget_module' => $widget['widget_module'] ?? null, + 'presentation' => $widget['presentation'] ?? null, ) ); } From 0efdb2e177ca30555f2d18469c0013887863f0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 11 May 2026 12:19:44 -0300 Subject: [PATCH 03/26] pass presentation through wp-build manifest --- packages/wp-build/lib/build.mjs | 15 ++++++++++----- packages/wp-build/lib/widget-utils.mjs | 9 +++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 39e746630bec46..10488b9e45d9fa 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -1928,7 +1928,7 @@ async function buildAllWidgets() { * Discover all widgets and collect their registry-facing data. * Widgets without a valid widget.json are skipped. * - * @return {Array<{ name: string, dirName: string, hasRender: boolean, hasWidget: boolean }>} Array of widget objects. + * @return {Array<{ name: string, dirName: string, hasRender: boolean, hasWidget: boolean, presentation: string | null }>} Array of widget objects. */ function collectWidgets() { return getAllWidgets( ROOT_DIR ).flatMap( ( widgetName ) => { @@ -1949,6 +1949,7 @@ function collectWidgets() { dirName: widgetName, hasRender: widgetFiles.hasRender, hasWidget: widgetFiles.hasWidget, + presentation: metadata.presentation ?? null, }, ]; } ); @@ -1974,11 +1975,15 @@ async function generateWidgetRegistry( widgets, replacements ) { .map( ( widget ) => { const hasRenderStr = widget.hasRender ? 'true' : 'false'; const hasWidgetStr = widget.hasWidget ? 'true' : 'false'; + const presentationStr = widget.presentation + ? `'${ widget.presentation }'` + : 'null'; return `\tarray( - 'name' => '${ widget.name }', - 'dir_name' => '${ widget.dirName }', - 'has_render' => ${ hasRenderStr }, - 'has_widget' => ${ hasWidgetStr }, + 'name' => '${ widget.name }', + 'dir_name' => '${ widget.dirName }', + 'has_render' => ${ hasRenderStr }, + 'has_widget' => ${ hasWidgetStr }, + 'presentation' => ${ presentationStr }, )`; } ) .join( ',\n' ); diff --git a/packages/wp-build/lib/widget-utils.mjs b/packages/wp-build/lib/widget-utils.mjs index 998eaee554ab3e..364a27932bf39c 100644 --- a/packages/wp-build/lib/widget-utils.mjs +++ b/packages/wp-build/lib/widget-utils.mjs @@ -25,10 +25,11 @@ export function getAllWidgets( rootDir ) { /** * @typedef {Object} WidgetMetadata - * @property {string} name Widget namespaced identifier. - * @property {string} [title] Human-readable title. - * @property {string} [description] Short description. - * @property {string} [category] Grouping category. + * @property {string} name Widget namespaced identifier. + * @property {string} [title] Human-readable title. + * @property {string} [description] Short description. + * @property {string} [category] Grouping category. + * @property {'framed' | 'full-bleed'} [presentation] Authoring intent about how the widget wants to render. */ /** From 9e02c75c1c25938c0c426e237099310609e8b084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 11 May 2026 12:19:54 -0300 Subject: [PATCH 04/26] map presentation in widget-types resolver --- routes/dashboard/widget-types/hooks/use-widget-types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routes/dashboard/widget-types/hooks/use-widget-types.ts b/routes/dashboard/widget-types/hooks/use-widget-types.ts index 9970c8d1961085..4b8a14ccdf5206 100644 --- a/routes/dashboard/widget-types/hooks/use-widget-types.ts +++ b/routes/dashboard/widget-types/hooks/use-widget-types.ts @@ -39,6 +39,7 @@ interface WidgetModuleRecord { name: string; render_module?: string | null; widget_module?: string | null; + presentation?: 'framed' | 'full-bleed' | null; } /** @@ -92,6 +93,9 @@ export function useWidgetTypes(): WidgetType[] { ...( module.default as Partial< WidgetType > ), name: record.name as WidgetName, renderModule: record.render_module ?? '', + ...( record.presentation + ? { presentation: record.presentation } + : {} ), } as WidgetType; } catch { return null; From 13fa997f996c902e354c19b5ad045cdbae5d6f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 11 May 2026 12:20:04 -0300 Subject: [PATCH 05/26] honor full-bleed presentation in widget chrome --- .../widget-chrome/widget-chrome.module.css | 18 +++++++++ .../widget-chrome/widget-chrome.tsx | 38 ++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.module.css b/routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.module.css index 39d6b6d98c1920..bce449d18b1dbd 100644 --- a/routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.module.css +++ b/routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.module.css @@ -9,6 +9,24 @@ .widgetChromeContent { flex: 1; + height: 100%; + + /* + * Probably remove the following padding + * once #77856 lands. + * @see https://github.com/WordPress/gutenberg/pull/77856 + */ + padding-top: 0; + padding-bottom: 0; +} + +/** + * Full-bleed widgets are rendered in a full-bleed container. + * Probably remove the following CSS once #77586 lands. + * @see https://github.com/WordPress/gutenberg/pull/77586 + */ +.widgetChromeContentFullBleed { + height: 100%; } .loading { diff --git a/routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.tsx b/routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.tsx index e6eb5708eaab4c..98daa0c97cfc48 100644 --- a/routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.tsx +++ b/routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.tsx @@ -17,7 +17,7 @@ import { import { __ } from '@wordpress/i18n'; // Dashboard is still experimental. // eslint-disable-next-line @wordpress/use-recommended-components -import { Card, Stack, Notice } from '@wordpress/ui'; +import { Card, Stack, Notice, VisuallyHidden } from '@wordpress/ui'; /** * Internal dependencies @@ -126,6 +126,16 @@ export const WidgetChrome = forwardRef< HTMLDivElement, WidgetChromeProps >( return null; } + const isFullBleed = widgetType.presentation === 'full-bleed'; + const header =
; + const body = ( + + }> + + + + ); + return ( ( aria-labelledby={ widgetType.title ? titleId : undefined } { ...( editMode ? { inert: '' } : {} ) } > -
+ { isFullBleed ? ( + { header } + ) : ( + header + ) } + - - }> - - - + { isFullBleed ? ( + + { body } + + ) : ( + body + ) } From 4f31f490e0f7c7cb8140943f64576f901bfd9f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 11 May 2026 12:20:15 -0300 Subject: [PATCH 06/26] update hello-world to showcase full-bleed --- widgets/hello-world/render.tsx | 17 ++++++++++++++++- widgets/hello-world/widget.json | 3 ++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/widgets/hello-world/render.tsx b/widgets/hello-world/render.tsx index 9d5dcd6259e033..592cd452a7293f 100644 --- a/widgets/hello-world/render.tsx +++ b/widgets/hello-world/render.tsx @@ -1,3 +1,18 @@ +import { Stack, Text } from '@wordpress/ui'; + export default function HelloWorld() { - return
Hello World
; + return ( + + Hello World + + ); } diff --git a/widgets/hello-world/widget.json b/widgets/hello-world/widget.json index f94cc461931ff0..86df48a7a01ccc 100644 --- a/widgets/hello-world/widget.json +++ b/widgets/hello-world/widget.json @@ -2,5 +2,6 @@ "name": "core/hello-world", "title": "Hello World", "description": "A minimal example widget.", - "category": "demo" + "category": "demo", + "presentation": "full-bleed" } From 9ce6e35c23bc6bd5d6ca99b803d50a7dd6f687f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 14:41:00 -0300 Subject: [PATCH 07/26] unify widget type identity in single source --- routes/dashboard/widget-dashboard/types.ts | 152 ++------------------- routes/dashboard/widget-types/types.ts | 5 + 2 files changed, 20 insertions(+), 137 deletions(-) diff --git a/routes/dashboard/widget-dashboard/types.ts b/routes/dashboard/widget-dashboard/types.ts index 65676e47674c62..812b763edb133b 100644 --- a/routes/dashboard/widget-dashboard/types.ts +++ b/routes/dashboard/widget-dashboard/types.ts @@ -1,5 +1,12 @@ /** - * Widget type definitions. + * Widget type definitions for the dashboard engine. + * + * The widget identity types (`WidgetName`, `WidgetTypeMetadata`, + * `WidgetType`) live in `routes/dashboard/widget-types/types` and are + * re-exported here so dashboard internals can pull every type they need + * from a single module. The local declarations below cover the + * dashboard-specific surface area: `DashboardWidget`, render props, + * module resolver, grid settings, and the `WidgetDashboard` prop bag. */ /** @@ -10,147 +17,18 @@ import type { ComponentType, ReactNode } from 'react'; /** * WordPress dependencies */ -import type { IconType } from '@wordpress/components'; -import type { Field } from '@wordpress/dataviews'; import type { DashboardGridLayoutItem } from '@wordpress/grid'; -/* - * MIGRATION: `WidgetName`, `WidgetTypeMetadata`, and `WidgetType` below - * are also defined in `@wordpress/widget-types` (currently on its own - * branch). When that package lands in trunk, replace the three - * declarations with: - * - * export type { - * WidgetName, - * WidgetTypeMetadata, - * WidgetType, - * } from '@wordpress/widget-types'; - * - * The shapes are kept identical on purpose so the swap is mechanical — - * any change to the fields here must land in lockstep on the - * `@wordpress/widget-types` package to keep the cutover trivial. - */ - /** - * Widget type identifier, structured as `/`. - * Both segments are lowercase, kebab-case; the full character pattern is - * enforced by the `widget.json` schema at authoring time. + * Internal dependencies */ -export type WidgetName = `${ string }/${ string }`; +import type { + WidgetName, + WidgetTypeMetadata, + WidgetType, +} from '../widget-types/types'; -/** - * Literal contents of a widget's `widget.json` metadata file. - * - * Captures the *authoring* shape only — module entry points and style - * assets are discovered by convention from the widget directory - * (`render.*`, `widget.*`, `render.scss`), not declared here. - * - * Consumed by tooling (IDE autocomplete, validation, the build pipeline). - * The dashboard engine consumes the richer `WidgetType` below, which - * extends this shape with runtime-only fields produced by the build - * manifest. - */ -export interface WidgetTypeMetadata { - /** - * Version of the Widget API used by the widget. - */ - apiVersion: number; - - /** - * Stable type identifier. See `WidgetName` for the shape. - */ - name: WidgetName; - - /** - * Display title; shown in the inserter. - */ - title: string; - - /** - * Short description shown in the widget inspector. - */ - description?: string; - - /** - * Visual identifier shown in the widget header; dashicon string, React node, or SVG component. - */ - icon?: IconType; - - /** - * Grouping category. Core provides `dashboard`; plugins and themes may - * register custom categories. - */ - category?: string; - - /** - * Authoring intent about how the widget wants to render. Static - * and declarative; not a user-editable attribute. - * - * - `'framed'` (default when absent): the widget renders its - * content only. - * - `'full-bleed'`: the widget renders edge-to-edge with no - * surrounding chrome. - */ - presentation?: 'framed' | 'full-bleed'; - - /** - * Search aliases used to surface the widget from the inserter. - */ - keywords?: string[]; - - /** - * Widget version — used for asset cache invalidation. - */ - version?: string; - - /** - * Gettext text domain for translations. - */ - textdomain?: string; - - /** - * Experiment gate — boolean `true`, or a specific experiment name. - */ - __experimental?: string | boolean; - - /** - * Declarative attribute schema. Surfaces render forms straight from - * this list via `DataForm`, with no per-widget form wiring. `any` is - * used here because the array is heterogeneous — each widget narrows - * `Item` to its own attribute type at the point of registration. - */ - attributes?: Field< any >[]; - - /** - * Structured example data for the Inspector Help Panel preview, and - * the default attributes applied by `createDashboardWidget` when no - * initial attributes are supplied. - */ - example?: { - attributes?: Record< string, unknown >; - }; -} - -/** - * Runtime widget type consumed by the dashboard engine. - * - * Extends `WidgetTypeMetadata` (the authoring shape of `widget.json`) with - * runtime-only fields produced by the build pipeline — notably - * `renderModule`, which maps each widget to its discovered script-module - * entry point. - * - * Surfaces consume `WidgetType[]` via the `widgetTypes` prop; the - * dashboard never reads the widget-types store directly. - */ -export interface WidgetType extends WidgetTypeMetadata { - /** - * Script-module identifier resolved to a React component at render - * time by `ResolveWidgetModule`. Produced by the build pipeline from - * the conventional `render.*` / `widget.*` entry points; not declared - * in `widget.json`. - */ - renderModule: string; -} +export type { WidgetName, WidgetTypeMetadata, WidgetType }; export type GridTilePlacement = Omit< DashboardGridLayoutItem, 'key' >; diff --git a/routes/dashboard/widget-types/types.ts b/routes/dashboard/widget-types/types.ts index fb12db81a066ee..380e87865ebe0a 100644 --- a/routes/dashboard/widget-types/types.ts +++ b/routes/dashboard/widget-types/types.ts @@ -85,6 +85,11 @@ export interface WidgetTypeMetadata { */ version?: string; + /** + * Gettext text domain for translations. + */ + textdomain?: string; + /** * Experiment gate — boolean `true`, or a specific experiment name. */ From 72a54fdfb1c19a66367fb294b3fcc195dc1a3d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 14:41:10 -0300 Subject: [PATCH 08/26] dedupe presentation declarations per layer --- .../class-wp-rest-widget-modules-controller.php | 2 +- .../dashboard-widgets/class-wp-widget-type.php | 11 +++++++++-- .../dashboard/widget-types/hooks/use-widget-types.ts | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php b/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php index 852aa8001ddb92..a0a7d3dc154b8f 100644 --- a/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php +++ b/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php @@ -241,7 +241,7 @@ public function get_item_schema() { 'presentation' => array( 'description' => __( 'Authoring intent about how the widget wants to render.', 'gutenberg' ), 'type' => array( 'string', 'null' ), - 'enum' => array( 'framed', 'full-bleed', null ), + 'enum' => array_merge( WP_Widget_Type::PRESENTATION_VALUES, array( null ) ), 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), diff --git a/lib/experimental/dashboard-widgets/class-wp-widget-type.php b/lib/experimental/dashboard-widgets/class-wp-widget-type.php index 586e75e9f14a61..df2f8aa2e66a40 100644 --- a/lib/experimental/dashboard-widgets/class-wp-widget-type.php +++ b/lib/experimental/dashboard-widgets/class-wp-widget-type.php @@ -22,6 +22,13 @@ #[AllowDynamicProperties] class WP_Widget_Type { + /** + * Allowed values for the `presentation` field. Treated as the + * single source of truth across the registry, REST schema, and + * any consumer that needs to validate or enumerate the set. + */ + const PRESENTATION_VALUES = array( 'framed', 'full-bleed' ); + /** * Widget type key. Namespaced identifier, e.g. `core/hello-world`. * @@ -53,8 +60,8 @@ class WP_Widget_Type { * Authoring intent about how the widget wants to render. Static * and declarative; not a user-editable attribute. * - * One of `'framed'` (default) or `'full-bleed'`. Null when the - * widget did not declare the field. + * One of {@see self::PRESENTATION_VALUES} (first entry is the + * default). Null when the widget did not declare the field. * * @var string|null */ diff --git a/routes/dashboard/widget-types/hooks/use-widget-types.ts b/routes/dashboard/widget-types/hooks/use-widget-types.ts index 4b8a14ccdf5206..2cd17fc4fe728a 100644 --- a/routes/dashboard/widget-types/hooks/use-widget-types.ts +++ b/routes/dashboard/widget-types/hooks/use-widget-types.ts @@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import type { WidgetName, WidgetType } from '../types'; +import type { WidgetName, WidgetType, WidgetTypeMetadata } from '../types'; /** * Registers the `widgetModule` core-data entity at module load. @@ -39,7 +39,7 @@ interface WidgetModuleRecord { name: string; render_module?: string | null; widget_module?: string | null; - presentation?: 'framed' | 'full-bleed' | null; + presentation?: WidgetTypeMetadata[ 'presentation' ] | null; } /** From 46f60a01228d473e42c83ab51f1aaa832fe39b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 15:24:45 -0300 Subject: [PATCH 09/26] add tsconfig umbrella for widgets/ extends tsconfig.base.json and registers the project in root references so widget sources are type-checked. --- tsconfig.json | 3 ++- widgets/tsconfig.json | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 widgets/tsconfig.json diff --git a/tsconfig.json b/tsconfig.json index dba162ff1cee44..b8edb76a36f853 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -93,7 +93,8 @@ { "path": "storybook" }, { "path": "test/e2e" }, { "path": "test/storybook-playwright" }, - { "path": "test/performance" } + { "path": "test/performance" }, + { "path": "widgets" } ], "files": [] } diff --git a/widgets/tsconfig.json b/widgets/tsconfig.json new file mode 100644 index 00000000000000..e5d1ac26273659 --- /dev/null +++ b/widgets/tsconfig.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "types": [ "style-imports", "react-css-custom-properties" ] + }, + "references": [ + { "path": "../packages/core-data" }, + { "path": "../packages/data" }, + { "path": "../packages/dataviews" }, + { "path": "../packages/date" }, + { "path": "../packages/element" }, + { "path": "../packages/html-entities" }, + { "path": "../packages/i18n" }, + { "path": "../packages/icons" }, + { "path": "../packages/ui" } + ], + "include": [ "**/*.ts", "**/*.tsx" ], + "exclude": [ "**/test/**", "**/build/**", "**/build-types/**" ] +} From b1e1bfef162a8520dc53e5cd6a884c0078270581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 15:24:52 -0300 Subject: [PATCH 10/26] add widgets/package.json declares clsx and @wordpress/* deps so the widget sources satisfy import/no-extraneous-dependencies. --- widgets/package.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 widgets/package.json diff --git a/widgets/package.json b/widgets/package.json new file mode 100644 index 00000000000000..0073c1c72c90f0 --- /dev/null +++ b/widgets/package.json @@ -0,0 +1,20 @@ +{ + "name": "@wordpress/widgets-app", + "version": "0.0.0", + "description": "Application widgets surfaced by the Gutenberg editor.", + "private": true, + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/core-data": "file:../packages/core-data", + "@wordpress/data": "file:../packages/data", + "@wordpress/dataviews": "file:../packages/dataviews", + "@wordpress/date": "file:../packages/date", + "@wordpress/element": "file:../packages/element", + "@wordpress/html-entities": "file:../packages/html-entities", + "@wordpress/i18n": "file:../packages/i18n", + "@wordpress/icons": "file:../packages/icons", + "@wordpress/ui": "file:../packages/ui", + "clsx": "^2.1.1" + } +} From f2916e286dff45cd5c4d395f6e623bdaf7863f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 15:43:16 -0300 Subject: [PATCH 11/26] add on_this_day REST param to posts endpoint Filters /wp/v2/posts by month and day across all years; gated under gutenberg-dashboard-widgets. --- lib/experimental/on-this-day/on-this-day.php | 73 ++++++++ lib/load.php | 1 + .../on-this-day/on-this-day-test.php | 158 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 lib/experimental/on-this-day/on-this-day.php create mode 100644 phpunit/experimental/on-this-day/on-this-day-test.php diff --git a/lib/experimental/on-this-day/on-this-day.php b/lib/experimental/on-this-day/on-this-day.php new file mode 100644 index 00000000000000..3d7c5f2827d70a --- /dev/null +++ b/lib/experimental/on-this-day/on-this-day.php @@ -0,0 +1,73 @@ + __( 'Limit response to posts published on the given month and day (MM-DD), regardless of year.', 'gutenberg' ), + 'type' => 'string', + 'pattern' => GUTENBERG_ON_THIS_DAY_PATTERN, + ); + + return $params; +} +add_filter( 'rest_post_collection_params', 'gutenberg_on_this_day_register_collection_param' ); + +/** + * Translates the `on_this_day` REST parameter into a `date_query` clause. + * + * Skips the translation when the parameter is missing or fails the schema + * pattern. The schema-level validation in `rest_post_collection_params` + * normally catches invalid input before this filter runs, but the + * defensive check keeps the filter safe for callers that bypass the + * controller's argument validation. + * + * @param array $args `WP_Query` arguments built by the controller. + * @param WP_REST_Request $request REST request being handled. + * @return array `WP_Query` arguments, extended with a `date_query` clause when applicable. + */ +function gutenberg_on_this_day_apply_query( $args, $request ) { + $value = $request['on_this_day'] ?? null; + + if ( ! is_string( $value ) || ! preg_match( '/' . GUTENBERG_ON_THIS_DAY_PATTERN . '/', $value ) ) { + return $args; + } + + [ $month, $day ] = array_map( 'intval', explode( '-', $value ) ); + + if ( ! isset( $args['date_query'] ) || ! is_array( $args['date_query'] ) ) { + $args['date_query'] = array(); + } + + $args['date_query'][] = array( + 'month' => $month, + 'day' => $day, + ); + + return $args; +} + +add_filter( 'rest_post_query', 'gutenberg_on_this_day_apply_query', 10, 2 ); diff --git a/lib/load.php b/lib/load.php index 237a3aeb8c28c9..db3dde5acb0e20 100644 --- a/lib/load.php +++ b/lib/load.php @@ -237,4 +237,5 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/dashboard-widgets/widget-types.php'; require __DIR__ . '/experimental/dashboard-widgets/dashboard-layout.php'; require __DIR__ . '/experimental/dashboard-widgets/default-layout-seed.php'; + require __DIR__ . '/experimental/on-this-day/on-this-day.php'; } diff --git a/phpunit/experimental/on-this-day/on-this-day-test.php b/phpunit/experimental/on-this-day/on-this-day-test.php new file mode 100644 index 00000000000000..e06ef141e5858d --- /dev/null +++ b/phpunit/experimental/on-this-day/on-this-day-test.php @@ -0,0 +1,158 @@ + + */ + protected static $post_ids = array(); + + public static function wpSetUpBeforeClass( $factory ) { + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$post_ids['oldest_may_11'] = $factory->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2018-05-11 10:00:00', + 'post_title' => 'Oldest May 11', + ) + ); + self::$post_ids['newer_may_11'] = $factory->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2020-05-11 10:00:00', + 'post_title' => 'Newer May 11', + ) + ); + self::$post_ids['other_day'] = $factory->post->create( + array( + 'post_status' => 'publish', + 'post_date' => '2019-07-04 10:00:00', + 'post_title' => 'Independence Day', + ) + ); + } + + public function set_up() { + parent::set_up(); + wp_set_current_user( self::$subscriber_id ); + } + + public function tear_down() { + wp_set_current_user( 0 ); + parent::tear_down(); + } + + public function test_collection_param_is_advertised_in_schema() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( self::ROUTE, $routes ); + + $get_route_args = null; + foreach ( $routes[ self::ROUTE ] as $route_handler ) { + if ( in_array( 'GET', (array) $route_handler['methods'], true ) || ! empty( $route_handler['methods']['GET'] ) ) { + $get_route_args = $route_handler['args']; + break; + } + } + + $this->assertNotNull( $get_route_args ); + $this->assertArrayHasKey( 'on_this_day', $get_route_args ); + $this->assertSame( 'string', $get_route_args['on_this_day']['type'] ); + $this->assertSame( GUTENBERG_ON_THIS_DAY_PATTERN, $get_route_args['on_this_day']['pattern'] ); + } + + public function test_filter_returns_only_posts_matching_month_day() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', '05-11' ); + $request->set_param( 'orderby', 'date' ); + $request->set_param( 'order', 'asc' ); + + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $ids = array_column( $response->get_data(), 'id' ); + sort( $ids ); + + $expected = array( self::$post_ids['oldest_may_11'], self::$post_ids['newer_may_11'] ); + sort( $expected ); + + $this->assertSame( $expected, $ids ); + } + + public function test_orderby_ascending_returns_oldest_match_first() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', '05-11' ); + $request->set_param( 'orderby', 'date' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'per_page', 1 ); + + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 1, $data ); + $this->assertSame( self::$post_ids['oldest_may_11'], $data[0]['id'] ); + } + + public function test_no_match_returns_empty_collection() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', '02-29' ); + + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + } + + public function test_missing_param_returns_all_published_posts() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + + $response = rest_do_request( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $ids = array_column( $response->get_data(), 'id' ); + $this->assertContains( self::$post_ids['oldest_may_11'], $ids ); + $this->assertContains( self::$post_ids['newer_may_11'], $ids ); + $this->assertContains( self::$post_ids['other_day'], $ids ); + } + + public function test_invalid_format_is_rejected_with_400() { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', '2026-05-11' ); + + $response = rest_do_request( $request ); + + $this->assertSame( 400, $response->get_status() ); + } + + public function test_query_filter_ignores_invalid_value_when_called_directly() { + $args = array( 'post_type' => 'post' ); + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'on_this_day', 'not-a-date' ); + + $filtered = gutenberg_on_this_day_apply_query( $args, $request ); + + $this->assertSame( $args, $filtered ); + } +} From c1612b7f0b479fafa11e7741a7371e0f82d8e554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 15:48:06 -0300 Subject: [PATCH 12/26] add On This Day dashboard widget Surfaces a published post resolved by a time range preset, with an optional featured image background and a configurable filter effect. --- .../components/on-this-day-view/index.ts | 1 + .../on-this-day-view/on-this-day-view.tsx | 137 ++++++++++ .../on-this-day-view/style.module.css | 32 +++ .../hooks/use-on-this-day-post/index.ts | 7 + .../use-on-this-day-post.ts | 249 ++++++++++++++++++ widgets/on-this-day/render.tsx | 34 +++ widgets/on-this-day/widget.json | 6 + widgets/on-this-day/widget.ts | 106 ++++++++ 8 files changed, 572 insertions(+) create mode 100644 widgets/on-this-day/components/on-this-day-view/index.ts create mode 100644 widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx create mode 100644 widgets/on-this-day/components/on-this-day-view/style.module.css create mode 100644 widgets/on-this-day/hooks/use-on-this-day-post/index.ts create mode 100644 widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts create mode 100644 widgets/on-this-day/render.tsx create mode 100644 widgets/on-this-day/widget.json create mode 100644 widgets/on-this-day/widget.ts diff --git a/widgets/on-this-day/components/on-this-day-view/index.ts b/widgets/on-this-day/components/on-this-day-view/index.ts new file mode 100644 index 00000000000000..e120b96f51a1c0 --- /dev/null +++ b/widgets/on-this-day/components/on-this-day-view/index.ts @@ -0,0 +1 @@ +export { OnThisDayView, type BackgroundEffect } from './on-this-day-view'; diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx new file mode 100644 index 00000000000000..7a7598a490bf82 --- /dev/null +++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx @@ -0,0 +1,137 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { decodeEntities } from '@wordpress/html-entities'; +import { humanTimeDiff } from '@wordpress/date'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { calendar } from '@wordpress/icons'; +import { EmptyState, Link, Stack, Text } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import type { UseOnThisDayPostResult } from '../../hooks/use-on-this-day-post'; +import styles from './style.module.css'; + +/** + * Named background filter presets. The widget exposes the chosen preset + * as an attribute; the surface picks the preset and the view resolves it + * to a CSS filter expression. + */ +export type BackgroundEffect = + | 'vintage' + | 'dim-saturate' + | 'dim-blur' + | 'grayscale' + | 'none'; + +export const BACKGROUND_EFFECTS: Record< BackgroundEffect, string > = { + vintage: 'sepia(0.3) brightness(0.55) saturate(0.9)', + 'dim-saturate': 'brightness(0.55) saturate(1.15)', + 'dim-blur': 'brightness(0.5) blur(2px)', + grayscale: 'grayscale(0.4) brightness(0.55)', + none: 'none', +}; + +/** + * View props: result of the data hook plus the background filter preset. + */ +export interface OnThisDayViewProps extends UseOnThisDayPostResult { + effect?: BackgroundEffect; +} + +/** + * Presentation for the `On This Day` widget. Decouples chrome and data + * fetching from the layout so Storybook and tests can drive each branch + * directly. + * @param root0 + * @param root0.post + * @param root0.isResolving + * @param root0.effect + */ +export function OnThisDayView( { + post, + isResolving, + effect = 'vintage', +}: OnThisDayViewProps ) { + if ( isResolving ) { + return ( + + { __( 'Loading…' ) } + + ); + } + + if ( ! post ) { + return ( + + + + { __( 'Nothing on this day yet' ) } + + + { __( 'No posts published on this date in past years.' ) } + + + ); + } + + const hasImage = !! post.featuredImageUrl; + const filterValue = BACKGROUND_EFFECTS[ effect ] ?? BACKGROUND_EFFECTS.none; + const relativeTime = sprintf( + /* translators: %s: Human-readable time difference, e.g. "2 days ago". */ + __( 'On this day, %s' ), + humanTimeDiff( post.date ) + ); + const containerStyle = hasImage + ? ( { + '--on-this-day-bg-image': `url(${ post.featuredImageUrl })`, + '--on-this-day-bg-filter': filterValue, + } as React.CSSProperties ) + : undefined; + + return ( + + + { relativeTime } + + + { decodeEntities( post.title.rendered ) } + + + { post.commentCount > 0 && ( + + + { sprintf( + /* translators: %d: number of comments */ + _n( + '%d comment', + '%d comments', + post.commentCount + ), + post.commentCount + ) } + + + ) } + + + ); +} diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css new file mode 100644 index 00000000000000..bf3637a4354057 --- /dev/null +++ b/widgets/on-this-day/components/on-this-day-view/style.module.css @@ -0,0 +1,32 @@ +.container { + position: relative; + overflow: hidden; + isolation: isolate; + min-height: 240px; +} + +.content { + padding: var(--wpds-dimension-padding-lg); +} + +.with-image .content { + background-color: color-mix(in srgb, var(--wpds-color-bg-surface-neutral-strong) 60%, transparent); + backdrop-filter: blur(2px); +} + +.with-image::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background-image: var(--on-this-day-bg-image); + background-position: center; + background-size: cover; + filter: var(--on-this-day-bg-filter, none); + transition: filter 300ms ease; +} + +.with-image:hover::before, +.with-image:focus-within::before { + filter: none; +} diff --git a/widgets/on-this-day/hooks/use-on-this-day-post/index.ts b/widgets/on-this-day/hooks/use-on-this-day-post/index.ts new file mode 100644 index 00000000000000..267f539563a148 --- /dev/null +++ b/widgets/on-this-day/hooks/use-on-this-day-post/index.ts @@ -0,0 +1,7 @@ +export { default as useOnThisDayPost } from './use-on-this-day-post'; +export type { + OnThisDayPost, + UseOnThisDayPostOptions, + UseOnThisDayPostResult, + TimeRange, +} from './use-on-this-day-post'; diff --git a/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts b/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts new file mode 100644 index 00000000000000..ab10433b10ae20 --- /dev/null +++ b/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts @@ -0,0 +1,249 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Named time-range presets selectable via the widget's `timeRange` + * attribute. `custom` requires a companion `customDate` (`YYYY-MM-DD`). + */ +export type TimeRange = + | 'one-year-ago' + | 'five-years-ago' + | 'ten-years-ago' + | 'oldest-on-this-day' + | 'custom'; + +/** + * Subset of the WordPress post REST shape consumed by the widget, + * normalized so the view does not need to know that the featured image + * URL comes from a separate `attachment` entity. + */ +export interface OnThisDayPost { + id: number; + date: string; + title: { rendered: string }; + excerpt: { rendered: string }; + link: string; + commentCount: number; + featuredImageUrl: string | null; +} + +/** + * Raw post record returned by the posts collection. `featured_media` is + * the attachment id; the URL is resolved with a second `getEntityRecord` + * call against the `attachment` entity. + */ +interface RawPost { + id: number; + date: string; + title: { rendered: string }; + excerpt: { rendered: string }; + link: string; + featured_media?: number; + comment_count?: string | number; +} + +/** + * Minimal subset of an `attachment` REST record consumed by the hook. + */ +interface AttachmentRecord { + id: number; + source_url?: string; +} + +/** + * Return value of {@link useOnThisDayPost}. + */ +export interface UseOnThisDayPostResult { + /** + * The matching post resolved by the selected time range, or `null` + * when no post matches or the request has not resolved yet. + */ + post: OnThisDayPost | null; + + /** + * `true` until the underlying `getEntityRecords` resolver has settled. + */ + isResolving: boolean; +} + +/** + * Hook options. All optional; defaults to the `one-year-ago` preset. + */ +export interface UseOnThisDayPostOptions { + timeRange?: TimeRange; + customDate?: string; +} + +/** + * Formats a `Date` as the `MM-DD` string expected by the + * `on_this_day` REST parameter. + * + * @param date Date to format. + * @return Zero-padded month and day separated by a dash. + */ +export function toMonthDay( date: Date ): string { + const month = String( date.getMonth() + 1 ).padStart( 2, '0' ); + const day = String( date.getDate() ).padStart( 2, '0' ); + return `${ month }-${ day }`; +} + +/** + * Formats a `Date` as `YYYY-MM-DD` in the local calendar, suitable for + * building `after`/`before` REST parameters against a specific day. + * + * @param date Date to format. + * @return Year, month, and day joined with dashes. + */ +function toYearMonthDay( date: Date ): string { + const year = String( date.getFullYear() ); + const month = String( date.getMonth() + 1 ).padStart( 2, '0' ); + const day = String( date.getDate() ).padStart( 2, '0' ); + return `${ year }-${ month }-${ day }`; +} + +/** + * Returns `now` shifted back by `years`. Calendar-safe via `setFullYear` + * (leap-day shifts to Feb 28 of the target year by JavaScript spec). + * + * @param now Reference date to shift back from. + * @param years Number of years to subtract. + * @return New `Date` shifted into the past. + */ +function shiftYears( now: Date, years: number ): Date { + const next = new Date( now ); + next.setFullYear( next.getFullYear() - years ); + return next; +} + +/** + * Translates the selected time range into the REST query fragment that + * narrows the posts collection to the matching day(s). + * + * - `one-year-ago` / `five-years-ago` / `ten-years-ago`: a single + * calendar day in the past, queried with native `after` / `before`. + * - `oldest-on-this-day`: matches today's month and day across all + * years, using the `on_this_day` filter registered by the plugin. + * - `custom`: a single calendar day specified by `customDate` (`YYYY-MM-DD`). + * + * @param timeRange Selected preset. + * @param customDate Calendar day for the `custom` preset; ignored otherwise. + * @param now Reference date used to anchor the relative presets. + * @return REST query fragment, or `null` when the inputs do not resolve + * to a valid query (e.g. `custom` without a parseable date). + */ +export function buildTimeRangeQuery( + timeRange: TimeRange, + customDate: string | undefined, + now: Date +): Record< string, string > | null { + if ( timeRange === 'oldest-on-this-day' ) { + return { on_this_day: toMonthDay( now ) }; + } + + let target: Date | null = null; + + if ( timeRange === 'one-year-ago' ) { + target = shiftYears( now, 1 ); + } else if ( timeRange === 'five-years-ago' ) { + target = shiftYears( now, 5 ); + } else if ( timeRange === 'ten-years-ago' ) { + target = shiftYears( now, 10 ); + } else if ( timeRange === 'custom' && customDate ) { + const parsed = new Date( `${ customDate }T00:00:00` ); + if ( ! Number.isNaN( parsed.getTime() ) ) { + target = parsed; + } + } + + if ( ! target ) { + return null; + } + + const ymd = toYearMonthDay( target ); + return { + after: `${ ymd }T00:00:00`, + before: `${ ymd }T23:59:59`, + }; +} + +/** + * Reads the post that matches the selected time range, then resolves + * its featured image via the `attachment` entity. + * + * Calls the native posts collection with either the `on_this_day=MM-DD` + * parameter (for the cross-year preset) or `after`/`before` (for a + * specific day). When the post has a non-zero `featured_media`, a + * second core-data selector fetches the attachment record; the URL is + * flattened into `featuredImageUrl` for the view. Both requests are + * cached and deduplicated by core-data. + * + * @param options Hook options. + * @param options.timeRange Preset that drives which post the hook surfaces. + * Defaults to `oldest-on-this-day`. + * @param options.customDate Calendar day (`YYYY-MM-DD`) used when + * `timeRange` is `custom`. + * @return The matching post and a resolution flag. + */ +export default function useOnThisDayPost( { + timeRange = 'oldest-on-this-day', + customDate, +}: UseOnThisDayPostOptions = {} ): UseOnThisDayPostResult { + return useSelect( + ( select ) => { + const { getEntityRecords, getEntityRecord } = select( coreStore ); + + const dateQuery = buildTimeRangeQuery( + timeRange, + customDate, + new Date() + ); + + if ( ! dateQuery ) { + return { post: null, isResolving: false }; + } + + const records = getEntityRecords( 'postType', 'post', { + ...dateQuery, + per_page: 1, + orderby: 'date', + order: 'asc', + _fields: + 'id,date,title,excerpt,link,featured_media,comment_count', + } ) as RawPost[] | null; + + if ( records === null ) { + return { post: null, isResolving: true }; + } + + const raw = records[ 0 ]; + + if ( ! raw ) { + return { post: null, isResolving: false }; + } + + const mediaId = raw.featured_media ?? 0; + const media = mediaId + ? ( getEntityRecord( 'postType', 'attachment', mediaId, { + context: 'view', + } ) as AttachmentRecord | undefined | null ) + : null; + + return { + post: { + id: raw.id, + date: raw.date, + title: raw.title, + excerpt: raw.excerpt, + link: raw.link, + commentCount: Number( raw.comment_count ?? 0 ), + featuredImageUrl: media?.source_url ?? null, + }, + isResolving: false, + }; + }, + [ timeRange, customDate ] + ); +} diff --git a/widgets/on-this-day/render.tsx b/widgets/on-this-day/render.tsx new file mode 100644 index 00000000000000..ec7b16b14cb34a --- /dev/null +++ b/widgets/on-this-day/render.tsx @@ -0,0 +1,34 @@ +/** + * Internal dependencies + */ +import { useOnThisDayPost } from './hooks/use-on-this-day-post'; +import type { TimeRange } from './hooks/use-on-this-day-post'; +import { + OnThisDayView, + type BackgroundEffect, +} from './components/on-this-day-view'; + +interface OnThisDayAttributes { + timeRange?: TimeRange; + customDate?: string; + effect?: BackgroundEffect; +} + +interface OnThisDayProps { + attributes: OnThisDayAttributes; +} + +/** + * Renders a published post resolved by the selected time range. The + * surrounding surface owns the chrome (header, footer, error boundary); + * this component emits only the widget body, delegating layout to + * @param { OnThisDayProps } props - The props for the On This Day widget. + */ +export default function OnThisDay( { attributes }: OnThisDayProps ) { + const data = useOnThisDayPost( { + timeRange: attributes.timeRange, + customDate: attributes.customDate, + } ); + + return ; +} diff --git a/widgets/on-this-day/widget.json b/widgets/on-this-day/widget.json new file mode 100644 index 00000000000000..083fb4aecf7995 --- /dev/null +++ b/widgets/on-this-day/widget.json @@ -0,0 +1,6 @@ +{ + "name": "core/on-this-day", + "title": "On This Day", + "description": "The oldest post published on this day in history.", + "category": "dashboard" +} diff --git a/widgets/on-this-day/widget.ts b/widgets/on-this-day/widget.ts new file mode 100644 index 00000000000000..50ae676fc304b5 --- /dev/null +++ b/widgets/on-this-day/widget.ts @@ -0,0 +1,106 @@ +/** + * WordPress dependencies + */ +import type { Field } from '@wordpress/dataviews'; +import { __ } from '@wordpress/i18n'; +import { calendar } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import type { BackgroundEffect } from './components/on-this-day-view'; +import type { TimeRange } from './hooks/use-on-this-day-post'; + +/** + * Shape of the widget's persisted attributes. Used to type the + * `attributes` schema and the `example` defaults so a typo in either + * surfaces as a TS error. + */ +type OnThisDayWidgetType = { + timeRange?: TimeRange; + customDate?: string; + effect?: BackgroundEffect; +}; + +const widget = { + apiVersion: 1, + name: 'core/on-this-day', + title: __( 'On This Day' ), + description: __( 'A post published on this day in history.' ), + icon: calendar, + keywords: [ + __( 'memory' ), + __( 'archive' ), + __( 'anniversary' ), + __( 'history' ), + ], + attributes: [ + { + id: 'timeRange', + label: __( 'Time range' ), + type: 'text', + elements: [ + { + value: 'one-year-ago', + label: __( 'One year ago' ), + }, + { + value: 'five-years-ago', + label: __( 'Five years ago' ), + }, + { + value: 'ten-years-ago', + label: __( 'Ten years ago' ), + }, + { + value: 'oldest-on-this-day', + label: __( 'Oldest on this day (any year)' ), + }, + { + value: 'custom', + label: __( 'Custom date' ), + }, + ], + }, + { + id: 'customDate', + label: __( 'Custom date (YYYY-MM-DD)' ), + type: 'text', + }, + { + id: 'effect', + label: __( 'Background effect' ), + type: 'text', + elements: [ + { + value: 'vintage', + label: __( 'Vintage memory' ), + }, + { + value: 'dim-saturate', + label: __( 'Dim + saturate' ), + }, + { + value: 'dim-blur', + label: __( 'Dim + blur' ), + }, + { + value: 'grayscale', + label: __( 'Grayscale' ), + }, + { + value: 'none', + label: __( 'No filter' ), + }, + ], + }, + ] satisfies Field< OnThisDayWidgetType >[], + example: { + attributes: { + timeRange: 'oldest-on-this-day', + effect: 'vintage', + } satisfies Partial< OnThisDayWidgetType >, + }, +}; + +export default widget; From ee5dacd83ce30ac6b81d634bdd39f96bb5585756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 15:52:52 -0300 Subject: [PATCH 13/26] use motion design tokens for image reveal transition --- .../on-this-day/components/on-this-day-view/style.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css index bf3637a4354057..7367ce7ac2b8dc 100644 --- a/widgets/on-this-day/components/on-this-day-view/style.module.css +++ b/widgets/on-this-day/components/on-this-day-view/style.module.css @@ -23,7 +23,7 @@ background-position: center; background-size: cover; filter: var(--on-this-day-bg-filter, none); - transition: filter 300ms ease; + transition: filter var(--wpds-motion-duration-lg) var(--wpds-motion-easing-expressive); } .with-image:hover::before, From 239991a718224750798fc862f970e0eb1675be21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 15:54:19 -0300 Subject: [PATCH 14/26] add required metadata to widgets/package.json bugs, homepage, keywords, publishConfig, repository fields required by package.json lint rules. --- widgets/package.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/widgets/package.json b/widgets/package.json index 0073c1c72c90f0..408477f32d355a 100644 --- a/widgets/package.json +++ b/widgets/package.json @@ -5,6 +5,20 @@ "private": true, "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "widgets" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/widgets", + "repository": { + "type": "git", + "url": "git+https://github.com/WordPress/gutenberg.git", + "directory": "widgets" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, "dependencies": { "@wordpress/core-data": "file:../packages/core-data", "@wordpress/data": "file:../packages/data", @@ -16,5 +30,8 @@ "@wordpress/icons": "file:../packages/icons", "@wordpress/ui": "file:../packages/ui", "clsx": "^2.1.1" + }, + "publishConfig": { + "access": "public" } } From 50b8b0e20bdda5fba286ec31a58f80f0674b73d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 18:56:37 -0300 Subject: [PATCH 15/26] make widget framework types generic Parametrize WidgetTypeMetadata and WidgetType over the widget's attribute object (Item). Add WidgetRenderProps. Promote the widget-types module to a composite TS project so consumers in other compilation units can reference it cleanly. --- routes/dashboard/tsconfig.json | 2 +- routes/dashboard/widget-types/index.ts | 7 ++- routes/dashboard/widget-types/tsconfig.json | 19 ++++++++ routes/dashboard/widget-types/types.ts | 53 +++++++++++++++------ 4 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 routes/dashboard/widget-types/tsconfig.json diff --git a/routes/dashboard/tsconfig.json b/routes/dashboard/tsconfig.json index ae3abe9d2d4bf8..d6f992df6b4959 100644 --- a/routes/dashboard/tsconfig.json +++ b/routes/dashboard/tsconfig.json @@ -11,5 +11,5 @@ "types": [ "style-imports" ] }, "include": [ "**/*.ts", "**/*.tsx" ], - "exclude": [ "**/test/**", "build", "node_modules" ] + "exclude": [ "**/test/**", "build", "node_modules", "widget-types" ] } diff --git a/routes/dashboard/widget-types/index.ts b/routes/dashboard/widget-types/index.ts index b76807da061f0a..c5743c48a1e7e4 100644 --- a/routes/dashboard/widget-types/index.ts +++ b/routes/dashboard/widget-types/index.ts @@ -6,4 +6,9 @@ export { useWidgetTypes } from './hooks'; /** * Types */ -export type { WidgetName, WidgetTypeMetadata, WidgetType } from './types'; +export type { + WidgetName, + WidgetTypeMetadata, + WidgetType, + WidgetRenderProps, +} from './types'; diff --git a/routes/dashboard/widget-types/tsconfig.json b/routes/dashboard/widget-types/tsconfig.json new file mode 100644 index 00000000000000..47a4279ec0c9da --- /dev/null +++ b/routes/dashboard/widget-types/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "declarationDir": "./build-types", + "types": [ "style-imports" ] + }, + "references": [ + { "path": "../../../packages/components" }, + { "path": "../../../packages/core-data" }, + { "path": "../../../packages/data" }, + { "path": "../../../packages/dataviews" }, + { "path": "../../../packages/element" }, + { "path": "../../../packages/i18n" } + ], + "include": [ "**/*.ts", "**/*.tsx" ], + "exclude": [ "**/test/**", "build-types", "node_modules" ] +} diff --git a/routes/dashboard/widget-types/types.ts b/routes/dashboard/widget-types/types.ts index 380e87865ebe0a..cc4ed681bf916c 100644 --- a/routes/dashboard/widget-types/types.ts +++ b/routes/dashboard/widget-types/types.ts @@ -4,6 +4,10 @@ * Canonical home for widget identity types consumed by the registry, * surfaces that render widgets, and tools that author them * (`@wordpress/build`, schema validators, IDE autocomplete). + * + * Each type is generic over the widget's attribute object (`Item`) so a + * widget binds its own attribute shape once and gets typed `attributes`, + * `example`, and `setAttributes` throughout the framework. */ /** @@ -23,7 +27,7 @@ export type WidgetName = `${ string }/${ string }`; /** * Literal contents of a widget's `widget.json` metadata file. * - * Captures the *authoring* shape only — module entry points and style + * Captures the *authoring* shape only; module entry points and style * assets are discovered by convention from the widget directory * (`render.*`, `widget.*`, `render.scss`), not declared here. * @@ -32,7 +36,7 @@ export type WidgetName = `${ string }/${ string }`; * which extends this shape with runtime-only fields produced by the * build manifest. */ -export interface WidgetTypeMetadata { +export interface WidgetTypeMetadata< Item = unknown > { /** * Version of the Widget API used by the widget. */ @@ -81,7 +85,7 @@ export interface WidgetTypeMetadata { keywords?: string[]; /** - * Widget version — used for asset cache invalidation. + * Widget version, used for asset cache invalidation. */ version?: string; @@ -91,26 +95,24 @@ export interface WidgetTypeMetadata { textdomain?: string; /** - * Experiment gate — boolean `true`, or a specific experiment name. + * Experiment gate; boolean `true`, or a specific experiment name. */ __experimental?: string | boolean; /** - * Declarative attribute schema, reusing the DataViews `Field` shape so - * surfaces can render forms via `DataForm` without per-widget form - * wiring. `Field< any >` is used here because the array is - * heterogeneous — each widget narrows `Item` to its own attribute type - * at the point of registration. + * Declarative attribute schema, bound to the widget's attribute + * object via `Item`. Surfaces render forms straight from this list + * via `DataForm`, with no per-widget form wiring. */ - attributes?: Field< any >[]; + attributes?: Field< Item >[]; /** - * Structured example data for the Inspector Help Panel preview, and the - * default attributes applied when a new instance is created without - * initial attributes. + * Structured example data for the Inspector Help Panel preview, and + * the default attributes applied when a new instance is created + * without initial attributes. */ example?: { - attributes?: Record< string, unknown >; + attributes?: Partial< Item >; }; } @@ -126,7 +128,8 @@ export interface WidgetTypeMetadata { * (`render_module`). The `getWidgetTypes` resolver is the single boundary * that maps it to the camelCase shape consumed throughout JS/TS. */ -export interface WidgetType extends WidgetTypeMetadata { +export interface WidgetType< Item = unknown > + extends WidgetTypeMetadata< Item > { /** * Script-module identifier resolved to a React component at render * time. Produced by the build pipeline from the conventional @@ -134,3 +137,23 @@ export interface WidgetType extends WidgetTypeMetadata { */ renderModule: string; } + +/** + * Props passed to a widget's render component by the consuming surface. + * + * Bound over `Item` so the destructured `attributes` and any + * `setAttributes` payload are typed against the widget's attribute + * object. + */ +export interface WidgetRenderProps< Item = unknown > { + /** + * User-configured attributes for this widget instance. + */ + attributes: Item; + + /** + * Updates the attributes of this instance. Optional because some + * surfaces render widgets in read-only contexts. + */ + setAttributes?: ( next: Partial< Item > ) => void; +} From 75217e6ab2add47f96c1b0a4def3bf6d97eaf14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 18:56:47 -0300 Subject: [PATCH 16/26] use generic widget types in on-this-day Bind WidgetTypeMetadata over the local OnThisDayWidgetType in widget.ts and WidgetRenderProps over OnThisDayAttributes in render.tsx. Add widget-types as a TS project reference so the import resolves cleanly without polluting the source tree. --- widgets/on-this-day/render.tsx | 14 ++++---- widgets/on-this-day/widget.ts | 59 +++++++++------------------------- widgets/tsconfig.json | 3 +- 3 files changed, 26 insertions(+), 50 deletions(-) diff --git a/widgets/on-this-day/render.tsx b/widgets/on-this-day/render.tsx index ec7b16b14cb34a..4c598d0eded213 100644 --- a/widgets/on-this-day/render.tsx +++ b/widgets/on-this-day/render.tsx @@ -1,6 +1,7 @@ /** * Internal dependencies */ +import type { WidgetRenderProps } from '../../routes/dashboard/widget-types'; import { useOnThisDayPost } from './hooks/use-on-this-day-post'; import type { TimeRange } from './hooks/use-on-this-day-post'; import { @@ -14,17 +15,18 @@ interface OnThisDayAttributes { effect?: BackgroundEffect; } -interface OnThisDayProps { - attributes: OnThisDayAttributes; -} - /** * Renders a published post resolved by the selected time range. The * surrounding surface owns the chrome (header, footer, error boundary); * this component emits only the widget body, delegating layout to - * @param { OnThisDayProps } props - The props for the On This Day widget. + * {@link OnThisDayView}. + * + * @param props Component props. + * @param props.attributes Widget attributes set on this instance. */ -export default function OnThisDay( { attributes }: OnThisDayProps ) { +export default function OnThisDay( { + attributes, +}: WidgetRenderProps< OnThisDayAttributes > ) { const data = useOnThisDayPost( { timeRange: attributes.timeRange, customDate: attributes.customDate, diff --git a/widgets/on-this-day/widget.ts b/widgets/on-this-day/widget.ts index 50ae676fc304b5..02ba8805c76c15 100644 --- a/widgets/on-this-day/widget.ts +++ b/widgets/on-this-day/widget.ts @@ -1,20 +1,20 @@ /** * WordPress dependencies */ -import type { Field } from '@wordpress/dataviews'; import { __ } from '@wordpress/i18n'; import { calendar } from '@wordpress/icons'; /** * Internal dependencies */ +import type { WidgetTypeMetadata } from '../../routes/dashboard/widget-types'; import type { BackgroundEffect } from './components/on-this-day-view'; import type { TimeRange } from './hooks/use-on-this-day-post'; /** - * Shape of the widget's persisted attributes. Used to type the - * `attributes` schema and the `example` defaults so a typo in either - * surfaces as a TS error. + * Shape of the widget's persisted attributes. Bound to + * `WidgetTypeMetadata` below so the attribute schema and the example + * defaults are validated against this type. */ type OnThisDayWidgetType = { timeRange?: TimeRange; @@ -22,7 +22,7 @@ type OnThisDayWidgetType = { effect?: BackgroundEffect; }; -const widget = { +const widget: WidgetTypeMetadata< OnThisDayWidgetType > = { apiVersion: 1, name: 'core/on-this-day', title: __( 'On This Day' ), @@ -40,26 +40,14 @@ const widget = { label: __( 'Time range' ), type: 'text', elements: [ - { - value: 'one-year-ago', - label: __( 'One year ago' ), - }, - { - value: 'five-years-ago', - label: __( 'Five years ago' ), - }, - { - value: 'ten-years-ago', - label: __( 'Ten years ago' ), - }, + { value: 'one-year-ago', label: __( 'One year ago' ) }, + { value: 'five-years-ago', label: __( 'Five years ago' ) }, + { value: 'ten-years-ago', label: __( 'Ten years ago' ) }, { value: 'oldest-on-this-day', label: __( 'Oldest on this day (any year)' ), }, - { - value: 'custom', - label: __( 'Custom date' ), - }, + { value: 'custom', label: __( 'Custom date' ) }, ], }, { @@ -72,34 +60,19 @@ const widget = { label: __( 'Background effect' ), type: 'text', elements: [ - { - value: 'vintage', - label: __( 'Vintage memory' ), - }, - { - value: 'dim-saturate', - label: __( 'Dim + saturate' ), - }, - { - value: 'dim-blur', - label: __( 'Dim + blur' ), - }, - { - value: 'grayscale', - label: __( 'Grayscale' ), - }, - { - value: 'none', - label: __( 'No filter' ), - }, + { value: 'vintage', label: __( 'Vintage memory' ) }, + { value: 'dim-saturate', label: __( 'Dim + saturate' ) }, + { value: 'dim-blur', label: __( 'Dim + blur' ) }, + { value: 'grayscale', label: __( 'Grayscale' ) }, + { value: 'none', label: __( 'No filter' ) }, ], }, - ] satisfies Field< OnThisDayWidgetType >[], + ], example: { attributes: { timeRange: 'oldest-on-this-day', effect: 'vintage', - } satisfies Partial< OnThisDayWidgetType >, + }, }, }; diff --git a/widgets/tsconfig.json b/widgets/tsconfig.json index e5d1ac26273659..8916eded636ccf 100644 --- a/widgets/tsconfig.json +++ b/widgets/tsconfig.json @@ -14,7 +14,8 @@ { "path": "../packages/html-entities" }, { "path": "../packages/i18n" }, { "path": "../packages/icons" }, - { "path": "../packages/ui" } + { "path": "../packages/ui" }, + { "path": "../routes/dashboard/widget-types" } ], "include": [ "**/*.ts", "**/*.tsx" ], "exclude": [ "**/test/**", "**/build/**", "**/build-types/**" ] From 630257099fa6e93b70c906ad76ed4bc44e661f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 19:18:36 -0300 Subject: [PATCH 17/26] declare full-bleed presentation on on-this-day Hints the surface to render this widget edge-to-edge so the featured image background fills the tile. Fill the tile height via `height: 100%` so the layout follows. --- .../on-this-day/components/on-this-day-view/style.module.css | 1 + widgets/on-this-day/widget.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css index 7367ce7ac2b8dc..3108bbff4df59c 100644 --- a/widgets/on-this-day/components/on-this-day-view/style.module.css +++ b/widgets/on-this-day/components/on-this-day-view/style.module.css @@ -3,6 +3,7 @@ overflow: hidden; isolation: isolate; min-height: 240px; + height: 100%; } .content { diff --git a/widgets/on-this-day/widget.json b/widgets/on-this-day/widget.json index 083fb4aecf7995..17b27f039b29d1 100644 --- a/widgets/on-this-day/widget.json +++ b/widgets/on-this-day/widget.json @@ -2,5 +2,6 @@ "name": "core/on-this-day", "title": "On This Day", "description": "The oldest post published on this day in history.", - "category": "dashboard" + "category": "dashboard", + "presentation": "full-bleed" } From f9db55b85d665e51762bbbd08ac604caf1699cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 12 May 2026 19:18:42 -0300 Subject: [PATCH 18/26] stop emitting types from widgets tsconfig Setting `composite: false` + `noEmit: true` prevents tsc from writing declarations into `widgets/build-types/`, which wp-build was scanning as if it were a widget directory. The widgets root is a leaf consumer: no other project references it, so it does not need to be composite to reference upstream projects. --- widgets/tsconfig.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/widgets/tsconfig.json b/widgets/tsconfig.json index 8916eded636ccf..7239bffeed2afe 100644 --- a/widgets/tsconfig.json +++ b/widgets/tsconfig.json @@ -2,6 +2,9 @@ "$schema": "https://json.schemastore.org/tsconfig.json", "extends": "../tsconfig.base.json", "compilerOptions": { + "composite": false, + "noEmit": true, + "emitDeclarationOnly": false, "rootDir": ".", "types": [ "style-imports", "react-css-custom-properties" ] }, From c12d862ef7c09bfb9f2c043d28afd16f3a5421c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 13 May 2026 11:04:11 -0300 Subject: [PATCH 19/26] polish empty state --- .../components/on-this-day-view/on-this-day-view.tsx | 9 ++++----- .../components/on-this-day-view/style.module.css | 7 +++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx index 7a7598a490bf82..eae7c45d8b1576 100644 --- a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx +++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx @@ -49,10 +49,9 @@ export interface OnThisDayViewProps extends UseOnThisDayPostResult { * Presentation for the `On This Day` widget. Decouples chrome and data * fetching from the layout so Storybook and tests can drive each branch * directly. - * @param root0 - * @param root0.post - * @param root0.isResolving - * @param root0.effect + * + * @param {OnThisDayViewProps} props - The props for the `On This Day` widget. + * @return {React.ReactNode} - The `On This Day` widget. */ export function OnThisDayView( { post, @@ -69,7 +68,7 @@ export function OnThisDayView( { if ( ! post ) { return ( - + { __( 'Nothing on this day yet' ) } diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css index 3108bbff4df59c..7a0d81d7a978b4 100644 --- a/widgets/on-this-day/components/on-this-day-view/style.module.css +++ b/widgets/on-this-day/components/on-this-day-view/style.module.css @@ -6,6 +6,13 @@ height: 100%; } +.noPostsToday { + height: 100%; + justify-content: center; + align-items: center; + max-width: 100%; +} + .content { padding: var(--wpds-dimension-padding-lg); } From 92fec46a12ebf8eb8f50dd6260003a2a2dd1dae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 13 May 2026 14:32:17 -0300 Subject: [PATCH 20/26] add empty state CTA and rename style classes --- .../components/on-this-day-view/on-this-day-view.tsx | 11 ++++++++--- .../components/on-this-day-view/style.module.css | 10 ++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx index eae7c45d8b1576..3b45bb133faeab 100644 --- a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx +++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx @@ -10,7 +10,8 @@ import { decodeEntities } from '@wordpress/html-entities'; import { humanTimeDiff } from '@wordpress/date'; import { __, _n, sprintf } from '@wordpress/i18n'; import { calendar } from '@wordpress/icons'; -import { EmptyState, Link, Stack, Text } from '@wordpress/ui'; +// eslint-disable-next-line @wordpress/use-recommended-components +import { EmptyState, Link, Stack, Text, Button } from '@wordpress/ui'; /** * Internal dependencies @@ -68,7 +69,7 @@ export function OnThisDayView( { if ( ! post ) { return ( - + { __( 'Nothing on this day yet' ) } @@ -76,6 +77,10 @@ export function OnThisDayView( { { __( 'No posts published on this date in past years.' ) } + + + + ); } @@ -104,7 +109,7 @@ export function OnThisDayView( { ) } style={ containerStyle } > - + { relativeTime } Date: Wed, 13 May 2026 15:52:51 -0300 Subject: [PATCH 21/26] wire empty state CTA to post-new admin entry --- .../on-this-day-view/on-this-day-view.tsx | 14 ++++++++++++-- .../on-this-day-view/style.module.css | 19 +++++++++++++++++++ widgets/package.json | 1 + widgets/tsconfig.json | 1 + 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx index 3b45bb133faeab..4b57bfa1a95485 100644 --- a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx +++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx @@ -12,6 +12,7 @@ import { __, _n, sprintf } from '@wordpress/i18n'; import { calendar } from '@wordpress/icons'; // eslint-disable-next-line @wordpress/use-recommended-components import { EmptyState, Link, Stack, Text, Button } from '@wordpress/ui'; +import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -75,11 +76,20 @@ export function OnThisDayView( { { __( 'Nothing on this day yet' ) } - { __( 'No posts published on this date in past years.' ) } + { __( 'Your blogging memories are still being made.' ) } + { __( 'Check back again soon.' ) } - + ); diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css index f44b9b084814ab..acb9c8dff8f7e9 100644 --- a/widgets/on-this-day/components/on-this-day-view/style.module.css +++ b/widgets/on-this-day/components/on-this-day-view/style.module.css @@ -13,6 +13,25 @@ max-width: 100%; } +/* + * Neutralize wp-admin's global anchor styles when the action CTA renders + * the Button as ``. They live outside CSS layers and would beat the + * Button's own layered rules, so we restore the Button's foreground + * variables explicitly at a specificity that wp-admin cannot win. + */ +.no-posts-today a:any-link { + color: var(--wp-ui-button-foreground-color); + text-decoration: none; +} + +.no-posts-today a:any-link:hover, +.no-posts-today a:any-link:focus, +.no-posts-today a:any-link:active { + color: var(--wp-ui-button-foreground-color-active); + text-decoration: none; +} + + .header { padding: var(--wpds-dimension-padding-lg); border-start-start-radius: var(--wpds-border-radius-lg); diff --git a/widgets/package.json b/widgets/package.json index 408477f32d355a..7e81f1b8ddbdf4 100644 --- a/widgets/package.json +++ b/widgets/package.json @@ -29,6 +29,7 @@ "@wordpress/i18n": "file:../packages/i18n", "@wordpress/icons": "file:../packages/icons", "@wordpress/ui": "file:../packages/ui", + "@wordpress/url": "file:../packages/url", "clsx": "^2.1.1" }, "publishConfig": { diff --git a/widgets/tsconfig.json b/widgets/tsconfig.json index 7239bffeed2afe..7882b230a3ba63 100644 --- a/widgets/tsconfig.json +++ b/widgets/tsconfig.json @@ -18,6 +18,7 @@ { "path": "../packages/i18n" }, { "path": "../packages/icons" }, { "path": "../packages/ui" }, + { "path": "../packages/url" }, { "path": "../routes/dashboard/widget-types" } ], "include": [ "**/*.ts", "**/*.tsx" ], From 1c8099a371337342c5ab259ee3ba136b5e4f1dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 13 May 2026 16:37:02 -0300 Subject: [PATCH 22/26] show first-post CTA only when no posts exist --- .../on-this-day-view/on-this-day-view.tsx | 32 +++++++++++-------- .../use-on-this-day-post.ts | 32 +++++++++++++++++-- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx index 4b57bfa1a95485..648105111abd8a 100644 --- a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx +++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx @@ -58,8 +58,9 @@ export interface OnThisDayViewProps extends UseOnThisDayPostResult { export function OnThisDayView( { post, isResolving, + hasAnyPosts, effect = 'vintage', -}: OnThisDayViewProps ) { +}: OnThisDayViewProps ): React.ReactNode { if ( isResolving ) { return ( @@ -76,21 +77,24 @@ export function OnThisDayView( { { __( 'Nothing on this day yet' ) } - { __( 'Your blogging memories are still being made.' ) } - { __( 'Check back again soon.' ) } + { __( + 'Your blogging memories are still being made. Check back again soon.' + ) } - - - + { ! hasAnyPosts && ( + + + + ) } ); } diff --git a/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts b/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts index ab10433b10ae20..b5bf013b96a221 100644 --- a/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts +++ b/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts @@ -67,6 +67,14 @@ export interface UseOnThisDayPostResult { * `true` until the underlying `getEntityRecords` resolver has settled. */ isResolving: boolean; + + /** + * Whether the user has at least one published post overall. Used by + * the surface to bifurcate the empty state between "write your first + * post" (no history) and "broaden the search" (history exists but + * nothing matches the current filter). + */ + hasAnyPosts: boolean; } /** @@ -195,6 +203,15 @@ export default function useOnThisDayPost( { ( select ) => { const { getEntityRecords, getEntityRecord } = select( coreStore ); + const anyPostsProbe = getEntityRecords( 'postType', 'post', { + per_page: 1, + status: 'publish', + _fields: 'id', + } ) as Array< { id: number } > | null; + const probeIsResolving = anyPostsProbe === null; + const hasAnyPosts = + anyPostsProbe !== null && anyPostsProbe.length > 0; + const dateQuery = buildTimeRangeQuery( timeRange, customDate, @@ -202,7 +219,11 @@ export default function useOnThisDayPost( { ); if ( ! dateQuery ) { - return { post: null, isResolving: false }; + return { + post: null, + isResolving: probeIsResolving, + hasAnyPosts, + }; } const records = getEntityRecords( 'postType', 'post', { @@ -215,13 +236,17 @@ export default function useOnThisDayPost( { } ) as RawPost[] | null; if ( records === null ) { - return { post: null, isResolving: true }; + return { post: null, isResolving: true, hasAnyPosts }; } const raw = records[ 0 ]; if ( ! raw ) { - return { post: null, isResolving: false }; + return { + post: null, + isResolving: probeIsResolving, + hasAnyPosts, + }; } const mediaId = raw.featured_media ?? 0; @@ -242,6 +267,7 @@ export default function useOnThisDayPost( { featuredImageUrl: media?.source_url ?? null, }, isResolving: false, + hasAnyPosts, }; }, [ timeRange, customDate ] From 2798d5ac21aaac2e3cf018123e6528f9ae1bfd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 13 May 2026 16:38:51 -0300 Subject: [PATCH 23/26] break line in memories comment --- .../components/on-this-day-view/on-this-day-view.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx index 648105111abd8a..a3d447f5b7c4bb 100644 --- a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx +++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx @@ -77,9 +77,9 @@ export function OnThisDayView( { { __( 'Nothing on this day yet' ) } - { __( - 'Your blogging memories are still being made. Check back again soon.' - ) } + { __( 'Your blogging memories are still being made.' ) } +
+ { __( 'Check back again soon.' ) }
{ ! hasAnyPosts && ( From 1bb2179ad16207788ef7109f93af4385f129c45c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 15 May 2026 10:42:16 +0100 Subject: [PATCH 24/26] On This Day: improve text contrast over image --- .../on-this-day-view/style.module.css | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css index acb9c8dff8f7e9..8c0ad6dbe7868a 100644 --- a/widgets/on-this-day/components/on-this-day-view/style.module.css +++ b/widgets/on-this-day/components/on-this-day-view/style.module.css @@ -38,16 +38,26 @@ border-start-end-radius: var(--wpds-border-radius-lg); } +/* + * Pin foreground tokens to their "on dark" variants when an image + * background is present. `--_gcd-heading-color: inherit` is the same + * escape hatch the UI package itself uses (e.g. `collapsible-card`) to + * let headings adopt the surrounding color instead of the default + * `--wpds-color-fg-content-neutral`. The Link in the title already + * renders as `unstyled`, so it inherits the same color. + */ .with-image .header { - background-color: color-mix(in srgb, var(--wpds-color-bg-surface-neutral-strong) 70%, transparent); - backdrop-filter: blur(2px); + --_gcd-heading-color: inherit; + + color: var(--wpds-color-fg-interactive-neutral-strong); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } .with-image::before { content: ""; position: absolute; inset: 0; - z-index: -1; + z-index: -2; background-image: var(--on-this-day-bg-image); background-position: center; background-size: cover; @@ -55,6 +65,25 @@ transition: filter var(--wpds-motion-duration-lg) var(--wpds-motion-easing-expressive); } +/* + * Readability scrim built from `--wpds-color-bg-interactive-neutral-strong` + * (the dark neutral that pairs with `fg-interactive-neutral-strong` above). + * Anchored to the top, where the header text lives, and faded out below + * so it does not flatten the rest of the image. Stays on hover so + * revealing the image at full fidelity never sacrifices legibility. + */ +.with-image::after { + --_on-this-day-scrim-strong: color-mix(in srgb, var(--wpds-color-bg-interactive-neutral-strong) 75%, transparent); + --_on-this-day-scrim-mid: color-mix(in srgb, var(--wpds-color-bg-interactive-neutral-strong) 35%, transparent); + + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + background: linear-gradient(to bottom, var(--_on-this-day-scrim-strong) 0%, var(--_on-this-day-scrim-mid) 45%, transparent 75%); +} + .with-image:hover::before, .with-image:focus-within::before { filter: none; From 3859bd66030aec7194053643ba5950a0562fe610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 15 May 2026 10:42:29 +0100 Subject: [PATCH 25/26] On This Day: default to dim-blur effect --- .../components/on-this-day-view/on-this-day-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx index a3d447f5b7c4bb..85d0e1473c5d1e 100644 --- a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx +++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx @@ -59,7 +59,7 @@ export function OnThisDayView( { post, isResolving, hasAnyPosts, - effect = 'vintage', + effect = 'dim-blur', }: OnThisDayViewProps ): React.ReactNode { if ( isResolving ) { return ( From adf08c58457bd93a0db4b560cc7027a276e023a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Sat, 16 May 2026 14:21:30 +0100 Subject: [PATCH 26/26] replace scrim with backdrop-filter glass card keeps header contrast consistent on any image background --- .../on-this-day-view/style.module.css | 48 ++++++------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css index 8c0ad6dbe7868a..54f1cbee7f065a 100644 --- a/widgets/on-this-day/components/on-this-day-view/style.module.css +++ b/widgets/on-this-day/components/on-this-day-view/style.module.css @@ -13,12 +13,8 @@ max-width: 100%; } -/* - * Neutralize wp-admin's global anchor styles when the action CTA renders - * the Button as `
`. They live outside CSS layers and would beat the - * Button's own layered rules, so we restore the Button's foreground - * variables explicitly at a specificity that wp-admin cannot win. - */ +/* wp-admin's global `a` styles sit outside CSS layers and would beat + * the Button's layered rules. Restore them at higher specificity. */ .no-posts-today a:any-link { color: var(--wp-ui-button-foreground-color); text-decoration: none; @@ -38,19 +34,22 @@ border-start-end-radius: var(--wpds-border-radius-lg); } -/* - * Pin foreground tokens to their "on dark" variants when an image - * background is present. `--_gcd-heading-color: inherit` is the same - * escape hatch the UI package itself uses (e.g. `collapsible-card`) to - * let headings adopt the surrounding color instead of the default - * `--wpds-color-fg-content-neutral`. The Link in the title already - * renders as `unstyled`, so it inherits the same color. - */ +/* Glass card: backdrop-filter dims only the pixels behind the text, + * so contrast holds on any image. Foreground uses + * `fg-interactive-neutral-strong` because the design system has no + * `fg-content-neutral-strong` variant. */ .with-image .header { --_gcd-heading-color: inherit; + align-self: flex-start; + margin: var(--wpds-dimension-padding-md); + padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg); + border-radius: var(--wpds-border-radius-md); color: var(--wpds-color-fg-interactive-neutral-strong); - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); + background: color-mix(in srgb, var(--wpds-color-bg-surface-neutral-strong) 25%, transparent); + backdrop-filter: blur(3px) brightness(0.55) saturate(0.25); + -webkit-backdrop-filter: blur(10px) brightness(0.55) saturate(0.65); + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); } .with-image::before { @@ -65,25 +64,6 @@ transition: filter var(--wpds-motion-duration-lg) var(--wpds-motion-easing-expressive); } -/* - * Readability scrim built from `--wpds-color-bg-interactive-neutral-strong` - * (the dark neutral that pairs with `fg-interactive-neutral-strong` above). - * Anchored to the top, where the header text lives, and faded out below - * so it does not flatten the rest of the image. Stays on hover so - * revealing the image at full fidelity never sacrifices legibility. - */ -.with-image::after { - --_on-this-day-scrim-strong: color-mix(in srgb, var(--wpds-color-bg-interactive-neutral-strong) 75%, transparent); - --_on-this-day-scrim-mid: color-mix(in srgb, var(--wpds-color-bg-interactive-neutral-strong) 35%, transparent); - - content: ""; - position: absolute; - inset: 0; - z-index: -1; - pointer-events: none; - background: linear-gradient(to bottom, var(--_on-this-day-scrim-strong) 0%, var(--_on-this-day-scrim-mid) 45%, transparent 75%); -} - .with-image:hover::before, .with-image:focus-within::before { filter: none;