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..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
@@ -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_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 142f2dd85955c1..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`.
*
@@ -49,6 +56,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 {@see self::PRESENTATION_VALUES} (first entry is the
+ * default). 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,
)
);
}
diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs
index 0beb8f82267658..0ae6c549245942 100755
--- a/packages/wp-build/lib/build.mjs
+++ b/packages/wp-build/lib/build.mjs
@@ -1952,7 +1952,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 ) => {
@@ -1973,6 +1973,7 @@ function collectWidgets() {
dirName: widgetName,
hasRender: widgetFiles.hasRender,
hasWidget: widgetFiles.hasWidget,
+ presentation: metadata.presentation ?? null,
},
];
} );
@@ -1998,11 +1999,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.
*/
/**
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
+ ) }
diff --git a/routes/dashboard/widget-dashboard/types.ts b/routes/dashboard/widget-dashboard/types.ts
index 763403f2dd3c39..ca5d404bb50a82 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,139 +17,21 @@ import type { ComponentType, ReactNode } from 'react';
/**
* WordPress dependencies
*/
-import type { IconType } from '@wordpress/components';
-import type { Field } from '@wordpress/dataviews';
import type {
DashboardGridLayoutItem,
DashboardLanesLayoutItem,
} 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.
- */
-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
- * 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.
+ * Internal dependencies
*/
-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;
-
- /**
- * 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 >;
- };
-}
+import type {
+ WidgetName,
+ WidgetTypeMetadata,
+ WidgetType,
+} from '../widget-types/types';
-/**
- * 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' >;
export type MasonryTilePlacement = Omit< DashboardLanesLayoutItem, 'key' >;
diff --git a/routes/dashboard/widget-types/hooks/use-widget-types.ts b/routes/dashboard/widget-types/hooks/use-widget-types.ts
index 9970c8d1961085..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,6 +39,7 @@ interface WidgetModuleRecord {
name: string;
render_module?: string | null;
widget_module?: string | null;
+ presentation?: WidgetTypeMetadata[ 'presentation' ] | 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;
diff --git a/routes/dashboard/widget-types/types.ts b/routes/dashboard/widget-types/types.ts
index 065201f2d70bd4..cc4ed681bf916c 100644
--- a/routes/dashboard/widget-types/types.ts
+++ b/routes/dashboard/widget-types/types.ts
@@ -68,6 +68,17 @@ export interface WidgetTypeMetadata< Item = unknown > {
*/
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.
*/
@@ -78,6 +89,11 @@ export interface WidgetTypeMetadata< Item = unknown > {
*/
version?: string;
+ /**
+ * Gettext text domain for translations.
+ */
+ textdomain?: string;
+
/**
* Experiment gate; boolean `true`, or a specific experiment name.
*/
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"
}