diff --git a/includes/Dashboard/AI_Capabilities_Widget.php b/includes/Dashboard/AI_Capabilities_Widget.php
new file mode 100644
index 00000000..f818f261
--- /dev/null
+++ b/includes/Dashboard/AI_Capabilities_Widget.php
@@ -0,0 +1,211 @@
+registry = $registry;
+ }
+
+ /**
+ * Renders the widget content.
+ *
+ * Shows Ability statistics and per-provider capabilities.
+ *
+ * @since x.x.x
+ */
+ public function render(): void {
+ ?>
+
+ render_abilities_summary(); ?>
+ render_provider_capabilities(); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ registry->get_feature( 'abilities-explorer' );
+ if ( $feature && $feature->is_enabled() ) :
+ ?>
+
+
+
+
+
+
+ getRegisteredProviderIds();
+
+ if ( empty( $provider_ids ) ) {
+ return;
+ }
+
+ ?>
+
+
+
+
+ getProviderClassName( $provider_id );
+
+ /** @var \WordPress\AiClient\Providers\Contracts\ProviderInterface $provider_class */
+ $metadata = $provider_class::metadata();
+ $model_dir = $provider_class::modelMetadataDirectory();
+ $models = $model_dir->listModelMetadata();
+ $capabilities = array();
+
+ foreach ( $models as $model ) {
+ foreach ( $model->getSupportedCapabilities() as $capability ) {
+ $capabilities[ $capability->value ] = true;
+ }
+ }
+
+ if ( empty( $capabilities ) ) {
+ continue;
+ }
+
+ ?>
+
+
+ getName() ); ?>
+
+
+ $unused ) : ?>
+
+ get_capability_label( (string) $cap_value ) ); ?>
+
+
+
+
+
+
+ __( 'Text Generation', 'ai' ),
+ CapabilityEnum::IMAGE_GENERATION => __( 'Image Generation', 'ai' ),
+ CapabilityEnum::TEXT_TO_SPEECH_CONVERSION => __( 'Text to Speech', 'ai' ),
+ CapabilityEnum::SPEECH_GENERATION => __( 'Speech Generation', 'ai' ),
+ CapabilityEnum::MUSIC_GENERATION => __( 'Music Generation', 'ai' ),
+ CapabilityEnum::VIDEO_GENERATION => __( 'Video Generation', 'ai' ),
+ CapabilityEnum::EMBEDDING_GENERATION => __( 'Embedding Generation', 'ai' ),
+ CapabilityEnum::CHAT_HISTORY => __( 'Chat History', 'ai' ),
+ );
+
+ return $labels[ $capability ] ?? $capability;
+ }
+}
diff --git a/includes/Dashboard/AI_Status_Widget.php b/includes/Dashboard/AI_Status_Widget.php
new file mode 100644
index 00000000..68bceb93
--- /dev/null
+++ b/includes/Dashboard/AI_Status_Widget.php
@@ -0,0 +1,232 @@
+registry = $registry;
+ }
+
+ /**
+ * Renders the widget content.
+ *
+ * Determines whether to show the getting-started checklist or
+ * the full status view based on setup completion.
+ *
+ * @since x.x.x
+ */
+ public function render(): void {
+ $has_credentials = has_ai_credentials();
+ $global_enabled = (bool) get_option( Settings_Registration::GLOBAL_OPTION, false );
+ $any_feature_on = $this->has_any_enabled_feature();
+
+ if ( $has_credentials && $global_enabled && $any_feature_on ) {
+ $this->render_status();
+ } else {
+ $this->render_getting_started( $has_credentials, $global_enabled, $any_feature_on );
+ }
+ }
+
+ /**
+ * Renders the getting-started checklist.
+ *
+ * @since x.x.x
+ *
+ * @param bool $has_credentials Whether any AI provider credentials are configured.
+ * @param bool $global_enabled Whether the global features toggle is on.
+ * @param bool $any_feature_on Whether at least one feature is enabled.
+ */
+ private function render_getting_started( bool $has_credentials, bool $global_enabled, bool $any_feature_on ): void {
+ $steps = array(
+ array(
+ 'done' => $has_credentials,
+ 'label' => __( 'Configure an AI provider', 'ai' ),
+ 'url' => admin_url( 'options-connectors.php' ),
+ ),
+ array(
+ 'done' => $global_enabled,
+ 'label' => __( 'Globally enable AI Features', 'ai' ),
+ 'url' => admin_url( 'options-general.php?page=ai' ),
+ ),
+ array(
+ 'done' => $any_feature_on,
+ 'label' => __( 'Enable an individual feature', 'ai' ),
+ 'url' => admin_url( 'options-general.php?page=ai' ),
+ ),
+ array(
+ 'done' => false,
+ 'label' => __( 'Try it out', 'ai' ),
+ 'url' => admin_url( 'post-new.php' ),
+ ),
+ );
+ ?>
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ get_ai_connectors();
+ $features = $this->registry->get_all_features();
+ ?>
+
+
+
+
+
+
+
+
+
+ -
+ is_enabled() ) : ?>
+
+
+
+
+ get_label() ); ?>
+
+
+
+
+
+
+
+
+
+
+ Connector info.
+ */
+ private function get_ai_connectors(): array {
+ $connectors = array();
+
+ if ( ! function_exists( 'wp_get_connectors' ) ) {
+ return $connectors;
+ }
+
+ foreach ( wp_get_connectors() as $slug => $connector_data ) {
+ if ( 'ai_provider' !== $connector_data['type'] ) {
+ continue;
+ }
+
+ $auth = $connector_data['authentication'];
+ $configured = ( 'api_key' === $auth['method']
+ && ! empty( $auth['setting_name'] )
+ && '' !== get_option( $auth['setting_name'], '' ) );
+
+ $connectors[] = array(
+ 'name' => $connector_data['name'] ?? $slug,
+ 'configured' => $configured,
+ );
+ }
+
+ return $connectors;
+ }
+
+ /**
+ * Checks whether any registered feature is individually enabled.
+ *
+ * @since x.x.x
+ *
+ * @return bool True if at least one feature is enabled.
+ */
+ private function has_any_enabled_feature(): bool {
+ foreach ( $this->registry->get_all_features() as $feature ) {
+ if ( $feature->is_enabled() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/includes/Dashboard/Dashboard_Widgets.php b/includes/Dashboard/Dashboard_Widgets.php
new file mode 100644
index 00000000..70bbc859
--- /dev/null
+++ b/includes/Dashboard/Dashboard_Widgets.php
@@ -0,0 +1,90 @@
+registry = $registry;
+ }
+
+ /**
+ * Hooks into WordPress to register dashboard widgets.
+ *
+ * @since x.x.x
+ */
+ public function init(): void {
+ add_action( 'wp_dashboard_setup', array( $this, 'register_widgets' ) );
+ }
+
+ /**
+ * Registers the dashboard widgets and enqueues styles.
+ *
+ * Only registers widgets for users with the `manage_options` capability.
+ *
+ * @since x.x.x
+ */
+ public function register_widgets(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ $status_widget = new AI_Status_Widget( $this->registry );
+
+ wp_add_dashboard_widget(
+ 'wpai_status',
+ __( 'AI Status', 'ai' ),
+ array( $status_widget, 'render' )
+ );
+
+ $capabilities_widget = new AI_Capabilities_Widget( $this->registry );
+
+ wp_add_dashboard_widget(
+ 'wpai_capabilities',
+ __( 'AI Capabilities', 'ai' ),
+ array( $capabilities_widget, 'render' )
+ );
+
+ Asset_Loader::enqueue_style( 'dashboard-widgets', 'admin/dashboard' );
+ }
+}
diff --git a/includes/bootstrap.php b/includes/bootstrap.php
index fc5ff406..0718bac2 100644
--- a/includes/bootstrap.php
+++ b/includes/bootstrap.php
@@ -208,10 +208,13 @@ function initialize_features(): void {
$settings_registration = new Settings_Registration( $registry );
$settings_registration->init();
- // Initialize admin settings page.
+ // Initialize admin settings page and dashboard widgets.
if ( is_admin() ) {
$settings_page = new Settings_Page( $registry );
$settings_page->init();
+
+ $dashboard_widgets = new Dashboard\Dashboard_Widgets( $registry );
+ $dashboard_widgets->init();
}
// Register our post-related WordPress Abilities.
diff --git a/src/admin/dashboard/index.scss b/src/admin/dashboard/index.scss
new file mode 100644
index 00000000..daae5d92
--- /dev/null
+++ b/src/admin/dashboard/index.scss
@@ -0,0 +1,173 @@
+/**
+ * AI Dashboard Widget Styles
+ *
+ * Uses BEM (Block Element Modifier) naming convention.
+ *
+ * @package WordPress\AI
+ * @since x.x.x
+ */
+
+/* Block: ai-dashboard-status */
+.ai-dashboard-status {
+
+ &__checklist {
+ margin: 0;
+ list-style: none;
+ }
+
+ &__step {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+
+ a {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline !important;
+ }
+ }
+ }
+
+ /* Two-column layout for status mode */
+ &__columns {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ }
+
+ &__column {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__section-title {
+ font-weight: 600 !important;
+ letter-spacing: 0.05em;
+ }
+
+ &__list {
+ margin: 0;
+ list-style: none;
+ }
+
+ &__list-item {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.1875rem 0;
+
+ .dashicons {
+ font-size: 1rem;
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+
+ &__column-link {
+ display: inline-block;
+ margin-top: 0.5rem;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ /* Icon modifiers */
+ &__icon--success {
+ color: #46b450;
+ }
+
+ &__icon--error {
+ color: #dc3232;
+ }
+
+ &__icon--neutral {
+ color: #a7aaad;
+ }
+}
+
+/* Block: ai-dashboard-capabilities */
+.ai-dashboard-capabilities {
+
+ &__stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ }
+
+ &__stat-card {
+ display: flex;
+ flex-direction: column;
+ padding: 0.5rem 0.25rem;
+ border: 1px solid #dcdcde;
+ border-radius: 0.25rem;
+ text-align: center;
+ }
+
+ &__stat-value {
+ font-size: 1.5rem;
+ font-weight: 600;
+ line-height: 1.2;
+ color: #3858e9;
+ }
+
+ &__stat-label {
+ font-size: 0.625rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ margin-top: 0.125rem;
+ }
+
+ &__section-title {
+ font-weight: 600 !important;
+ letter-spacing: 0.05em;
+ }
+
+ &__providers {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ &__provider {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ &__provider-name {
+ font-weight: 600;
+ }
+
+ &__provider-caps {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ &__cap-tag {
+ display: inline-block;
+ padding: 0.125rem 0.5rem;
+ font-size: 0.6875rem;
+ background: #f0f0f1;
+ border-radius: 1rem;
+ }
+
+ &__links {
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid #f0f0f0;
+
+ a {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+}
diff --git a/tests/Integration/Includes/Dashboard/AI_Capabilities_WidgetTest.php b/tests/Integration/Includes/Dashboard/AI_Capabilities_WidgetTest.php
new file mode 100644
index 00000000..5bab093f
--- /dev/null
+++ b/tests/Integration/Includes/Dashboard/AI_Capabilities_WidgetTest.php
@@ -0,0 +1,233 @@
+ 'Abilities Explorer',
+ 'description' => 'Test abilities explorer',
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function register(): void {}
+}
+
+/**
+ * AI_Capabilities_Widget test case.
+ *
+ * @since x.x.x
+ */
+class AI_Capabilities_WidgetTest extends WP_UnitTestCase {
+
+ /**
+ * Tear down after each test.
+ *
+ * @since x.x.x
+ */
+ public function tearDown(): void {
+ delete_option( 'wpai_features_enabled' );
+ delete_option( 'wpai_feature_abilities-explorer_enabled' );
+ parent::tearDown();
+ }
+
+ /**
+ * Tests that the widget renders the wrapper div.
+ *
+ * @since x.x.x
+ */
+ public function test_render_outputs_wrapper() {
+ $registry = new Registry();
+ $widget = new AI_Capabilities_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'ai-dashboard-capabilities',
+ $output,
+ 'Should render the capabilities wrapper div'
+ );
+ }
+
+ /**
+ * Tests that abilities summary renders stat cards when abilities API is available.
+ *
+ * @since x.x.x
+ */
+ public function test_render_abilities_summary_stat_cards() {
+ if ( ! function_exists( 'wp_get_abilities' ) ) {
+ $this->markTestSkipped( 'WordPress Abilities API not available.' );
+ }
+
+ $registry = new Registry();
+ $widget = new AI_Capabilities_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'ai-dashboard-capabilities__stat-card',
+ $output,
+ 'Should render stat cards'
+ );
+ $this->assertStringContainsString( 'Total Abilities', $output );
+ $this->assertStringContainsString( 'Core', $output );
+ $this->assertStringContainsString( 'Plugins', $output );
+ $this->assertStringContainsString( 'Theme', $output );
+ }
+
+ /**
+ * Tests that Abilities Explorer link shows when feature is enabled.
+ *
+ * @since x.x.x
+ */
+ public function test_abilities_explorer_link_shown_when_enabled() {
+ if ( ! function_exists( 'wp_get_abilities' ) ) {
+ $this->markTestSkipped( 'WordPress Abilities API not available.' );
+ }
+
+ update_option( 'wpai_features_enabled', true );
+ update_option( 'wpai_feature_abilities-explorer_enabled', true );
+
+ $registry = new Registry();
+ $registry->register_feature( new Capabilities_Test_Feature() );
+
+ $widget = new AI_Capabilities_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'ai-abilities-explorer',
+ $output,
+ 'Should link to Abilities Explorer'
+ );
+ }
+
+ /**
+ * Tests that Abilities Explorer link is hidden when feature is disabled.
+ *
+ * @since x.x.x
+ */
+ public function test_abilities_explorer_link_hidden_when_disabled() {
+ if ( ! function_exists( 'wp_get_abilities' ) ) {
+ $this->markTestSkipped( 'WordPress Abilities API not available.' );
+ }
+
+ $registry = new Registry();
+ $registry->register_feature( new Capabilities_Test_Feature() );
+
+ $widget = new AI_Capabilities_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringNotContainsString(
+ 'ai-abilities-explorer',
+ $output,
+ 'Should not link to Abilities Explorer when disabled'
+ );
+ }
+
+ /**
+ * Tests that provider capabilities section renders when AiClient is available.
+ *
+ * @since x.x.x
+ */
+ public function test_render_provider_capabilities() {
+ if ( ! class_exists( \WordPress\AiClient\AiClient::class ) ) {
+ $this->markTestSkipped( 'AiClient not available.' );
+ }
+
+ $registry = \WordPress\AiClient\AiClient::defaultRegistry();
+ $provider_ids = $registry->getRegisteredProviderIds();
+
+ if ( empty( $provider_ids ) ) {
+ $this->markTestSkipped( 'No AI providers registered.' );
+ }
+
+ $exp_registry = new Registry();
+ $widget = new AI_Capabilities_Widget( $exp_registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'Provider Capabilities',
+ $output,
+ 'Should render the Provider Capabilities heading'
+ );
+ $this->assertStringContainsString(
+ 'ai-dashboard-capabilities__cap-tag',
+ $output,
+ 'Should render capability tags'
+ );
+ }
+
+ /**
+ * Tests that capability labels are human-readable.
+ *
+ * @since x.x.x
+ */
+ public function test_capability_labels_are_human_readable() {
+ if ( ! class_exists( \WordPress\AiClient\AiClient::class ) ) {
+ $this->markTestSkipped( 'AiClient not available.' );
+ }
+
+ $registry = \WordPress\AiClient\AiClient::defaultRegistry();
+ $provider_ids = $registry->getRegisteredProviderIds();
+
+ if ( empty( $provider_ids ) ) {
+ $this->markTestSkipped( 'No AI providers registered.' );
+ }
+
+ $registry = new Registry();
+ $widget = new AI_Capabilities_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ // Should contain human-readable labels, not raw enum values.
+ $this->assertStringNotContainsString(
+ 'text_generation',
+ $output,
+ 'Should not show raw enum values'
+ );
+ }
+}
diff --git a/tests/Integration/Includes/Dashboard/AI_Status_WidgetTest.php b/tests/Integration/Includes/Dashboard/AI_Status_WidgetTest.php
new file mode 100644
index 00000000..d1d71d9f
--- /dev/null
+++ b/tests/Integration/Includes/Dashboard/AI_Status_WidgetTest.php
@@ -0,0 +1,269 @@
+ 'First Feature',
+ 'description' => 'A test feature',
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function register(): void {}
+}
+
+/**
+ * Stub feature B for status widget tests.
+ *
+ * @since x.x.x
+ */
+class Status_Test_Feature_B extends Abstract_Feature {
+
+ /**
+ * {@inheritDoc}
+ */
+ public static function get_id(): string {
+ return 'test-feature-b';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function load_metadata(): array {
+ return array(
+ 'label' => 'Second Feature',
+ 'description' => 'Another test feature',
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function register(): void {}
+}
+
+/**
+ * AI_Status_Widget test case.
+ *
+ * @since x.x.x
+ */
+class AI_Status_WidgetTest extends WP_UnitTestCase {
+
+ /**
+ * Tear down after each test.
+ *
+ * @since x.x.x
+ */
+ public function tearDown(): void {
+ delete_option( Settings_Registration::GLOBAL_OPTION );
+ delete_option( 'wpai_feature_test-feature-a_enabled' );
+ delete_option( 'wpai_feature_test-feature-b_enabled' );
+ parent::tearDown();
+ }
+
+ /**
+ * Tests that getting-started mode is rendered when setup is incomplete.
+ *
+ * Without any credentials or enabled features, the widget should
+ * always show the getting-started checklist.
+ *
+ * @since x.x.x
+ */
+ public function test_render_getting_started_mode() {
+ $registry = new Registry();
+ $widget = new AI_Status_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'ai-dashboard-status__checklist',
+ $output,
+ 'Should render the getting-started checklist'
+ );
+ }
+
+ /**
+ * Tests that getting-started mode shows all four checklist steps.
+ *
+ * @since x.x.x
+ */
+ public function test_getting_started_has_all_steps() {
+ $registry = new Registry();
+ $widget = new AI_Status_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'Configure an AI provider', $output );
+ $this->assertStringContainsString( 'Globally enable AI Features', $output );
+ $this->assertStringContainsString( 'Enable an individual feature', $output );
+ $this->assertStringContainsString( 'Try it out', $output );
+ }
+
+ /**
+ * Tests that incomplete steps show error icons.
+ *
+ * @since x.x.x
+ */
+ public function test_getting_started_shows_error_icons_for_incomplete_steps() {
+ $registry = new Registry();
+ $widget = new AI_Status_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'dashicons-dismiss',
+ $output,
+ 'Should show error icons for incomplete steps'
+ );
+ }
+
+ /**
+ * Tests that the global enabled step shows a success icon when enabled.
+ *
+ * @since x.x.x
+ */
+ public function test_getting_started_shows_success_for_global_enabled() {
+ update_option( Settings_Registration::GLOBAL_OPTION, true );
+
+ $registry = new Registry();
+ $widget = new AI_Status_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'dashicons-yes-alt',
+ $output,
+ 'Should show success icon for the enabled global toggle step'
+ );
+ }
+
+ /**
+ * Tests that enabling an feature shows its step as complete.
+ *
+ * @since x.x.x
+ */
+ public function test_getting_started_shows_success_for_enabled_feature() {
+ update_option( Settings_Registration::GLOBAL_OPTION, true );
+ update_option( 'wpai_feature_test-feature-a_enabled', true );
+
+ $registry = new Registry();
+ $registry->register_feature( new Status_Test_Feature_A() );
+
+ $widget = new AI_Status_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ // Still in getting-started mode (no credentials), but feature step is green.
+ $this->assertStringContainsString( 'ai-dashboard-status__checklist', $output );
+
+ // Count success icons — global enabled + feature enabled = at least 2.
+ $success_count = substr_count( $output, 'dashicons-yes-alt' );
+ $this->assertGreaterThanOrEqual(
+ 2,
+ $success_count,
+ 'Should have at least 2 success icons (global + feature enabled)'
+ );
+ }
+
+ /**
+ * Tests that the checklist links to the correct admin pages.
+ *
+ * @since x.x.x
+ */
+ public function test_getting_started_links_to_admin_pages() {
+ $registry = new Registry();
+ $widget = new AI_Status_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'options-connectors.php', $output, 'Should link to connectors page' );
+ $this->assertStringContainsString( 'page=ai', $output, 'Should link to features settings' );
+ $this->assertStringContainsString( 'post-new.php', $output, 'Should link to new post page' );
+ }
+
+ /**
+ * Tests that the widget renders without errors when the registry has features.
+ *
+ * @since x.x.x
+ */
+ public function test_render_with_multiple_features_in_registry() {
+ $registry = new Registry();
+ $registry->register_feature( new Status_Test_Feature_A() );
+ $registry->register_feature( new Status_Test_Feature_B() );
+
+ $widget = new AI_Status_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'ai-dashboard-status',
+ $output,
+ 'Should render without errors with multiple features'
+ );
+ }
+
+ /**
+ * Tests that the widget renders without errors when the registry is empty.
+ *
+ * @since x.x.x
+ */
+ public function test_render_with_empty_registry() {
+ $registry = new Registry();
+ $widget = new AI_Status_Widget( $registry );
+
+ ob_start();
+ $widget->render();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'ai-dashboard-status',
+ $output,
+ 'Should render cleanly with empty registry'
+ );
+ }
+}
diff --git a/tests/Integration/Includes/Dashboard/Dashboard_WidgetsTest.php b/tests/Integration/Includes/Dashboard/Dashboard_WidgetsTest.php
new file mode 100644
index 00000000..f12bc0c7
--- /dev/null
+++ b/tests/Integration/Includes/Dashboard/Dashboard_WidgetsTest.php
@@ -0,0 +1,111 @@
+init();
+
+ $this->assertIsInt(
+ has_action( 'wp_dashboard_setup', array( $widgets, 'register_widgets' ) ),
+ 'register_widgets should be hooked to wp_dashboard_setup'
+ );
+ }
+
+ /**
+ * Tests that register_widgets requires manage_options capability.
+ *
+ * @since x.x.x
+ */
+ public function test_register_widgets_requires_manage_options() {
+ if ( ! function_exists( 'wp_add_dashboard_widget' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/dashboard.php';
+ }
+
+ global $wp_meta_boxes;
+
+ $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber_id );
+
+ $registry = new Registry();
+ $widgets = new Dashboard_Widgets( $registry );
+ $widgets->register_widgets();
+
+ $status_registered = isset( $wp_meta_boxes['dashboard']['normal']['core']['wpai_status'] );
+
+ $this->assertFalse(
+ $status_registered,
+ 'Widgets should not be registered for subscribers'
+ );
+
+ // Clean up.
+ unset( $wp_meta_boxes['dashboard'] );
+ }
+
+ /**
+ * Tests that register_widgets registers both widgets for admin users.
+ *
+ * @since x.x.x
+ */
+ public function test_register_widgets_for_admin() {
+ if ( ! function_exists( 'wp_add_dashboard_widget' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/dashboard.php';
+ }
+
+ global $wp_meta_boxes;
+
+ $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin_id );
+
+ // Set the current screen to the dashboard so wp_add_dashboard_widget works.
+ set_current_screen( 'dashboard' );
+
+ $registry = new Registry();
+ $widgets = new Dashboard_Widgets( $registry );
+ $widgets->register_widgets();
+
+ // wp_add_dashboard_widget may place widgets in different priority levels.
+ $all_widgets = array();
+ foreach ( $wp_meta_boxes['dashboard'] ?? array() as $context ) {
+ foreach ( $context as $priority_widgets ) {
+ $all_widgets = array_merge( $all_widgets, array_keys( $priority_widgets ) );
+ }
+ }
+
+ $this->assertContains(
+ 'wpai_status',
+ $all_widgets,
+ 'AI Status widget should be registered'
+ );
+ $this->assertContains(
+ 'wpai_capabilities',
+ $all_widgets,
+ 'AI Capabilities widget should be registered'
+ );
+
+ // Clean up.
+ unset( $wp_meta_boxes['dashboard'] );
+ }
+}
diff --git a/webpack.config.js b/webpack.config.js
index bb929257..24d2026e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -19,6 +19,11 @@ module.exports = {
'src/admin/settings',
'index.scss'
),
+ 'admin/dashboard': path.resolve(
+ process.cwd(),
+ 'src/admin/dashboard',
+ 'index.scss'
+ ),
'experiments/abilities-explorer': path.resolve(
process.cwd(),
'src/experiments/abilities-explorer',