@elementor/editor-audits 4.3.3 → 4.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/index.d.mts +11 -1
  2. package/dist/index.d.ts +11 -1
  3. package/dist/index.js +1103 -1146
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +1075 -1108
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +17 -15
  8. package/src/api/page-context-client.ts +8 -5
  9. package/src/audits/accessibility-statement.ts +39 -0
  10. package/src/audits/accessibility-widget.ts +41 -0
  11. package/src/audits/cookie-policy.ts +1 -0
  12. package/src/audits/google-consent-mode.ts +4 -7
  13. package/src/audits/images-alt-text.ts +6 -4
  14. package/src/audits/images-inefficient-format.ts +66 -0
  15. package/src/audits/images-too-large.ts +23 -10
  16. package/src/audits/nested-boxed-containers.ts +5 -0
  17. package/src/components/audit-feedback.tsx +4 -1
  18. package/src/components/audit-panel.tsx +13 -13
  19. package/src/components/audit-toggle-icon.tsx +22 -0
  20. package/src/components/fix-violation-with-angie.tsx +18 -7
  21. package/src/components/pages/category-page.tsx +27 -3
  22. package/src/components/pages/overview-page.tsx +24 -29
  23. package/src/components/report-shell.tsx +10 -91
  24. package/src/components/rescan-button.tsx +38 -0
  25. package/src/components/severity-icons.tsx +5 -1
  26. package/src/components/severity-summary-cards.tsx +52 -0
  27. package/src/components/violation-cta-button.tsx +11 -2
  28. package/src/components/violation-icons.tsx +2 -2
  29. package/src/components/violation-row.tsx +120 -62
  30. package/src/hooks/use-audit-report.ts +5 -4
  31. package/src/hooks/use-audit-toggle-props.ts +2 -2
  32. package/src/hooks/use-single-expanded-card.ts +11 -0
  33. package/src/init.ts +2 -0
  34. package/src/register-audits.ts +6 -2
  35. package/src/runner.ts +15 -7
  36. package/src/store/selectors.ts +4 -0
  37. package/src/store/slice.ts +10 -1
  38. package/src/sync-report-staleness.ts +14 -0
  39. package/src/types.ts +11 -1
  40. package/src/utils/all-image-sources.ts +17 -0
  41. package/src/utils/alt-text-image-sources.ts +24 -0
  42. package/src/utils/atomic-background-image-sources.ts +56 -0
  43. package/src/utils/atomic-image-sources.ts +66 -0
  44. package/src/utils/audit-status-summary.ts +0 -57
  45. package/src/utils/background-image-sources.ts +32 -0
  46. package/src/utils/image-alt.ts +3 -1
  47. package/src/utils/image-like-sources.ts +5 -14
  48. package/src/utils/image-size-key.ts +8 -0
  49. package/src/utils/page-attachments.ts +7 -6
  50. package/src/utils/scan-accessibility-widgets.ts +7 -0
  51. package/src/audits/too-many-widgets.ts +0 -53
  52. package/src/components/audit-statuses-section.tsx +0 -65
  53. package/src/components/count-summary-circle.tsx +0 -66
  54. package/src/components/pages/all-audits-page.tsx +0 -65
  55. package/src/components/pages/issues-page.tsx +0 -65
  56. package/src/components/score-bar.tsx +0 -56
  57. package/src/components/score-circle.tsx +0 -60
  58. package/src/components/severity-filter-chips.tsx +0 -57
  59. package/src/utils/score-thresholds.ts +0 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elementor/editor-audits",
3
- "version": "4.3.3",
3
+ "version": "4.3.5",
4
4
  "private": false,
5
5
  "author": "Elementor Team",
6
6
  "homepage": "https://elementor.com/",
@@ -39,20 +39,22 @@
39
39
  "dev": "tsup --config=../../tsup.dev.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@elementor/editor": "4.3.3",
43
- "@elementor/editor-mcp": "4.3.3",
44
- "@elementor/editor-app-bar": "4.3.3",
45
- "@elementor/editor-elements": "4.3.3",
46
- "@elementor/editor-floating-panels": "4.3.3",
47
- "@elementor/editor-notifications": "4.3.3",
48
- "@elementor/editor-v1-adapters": "4.3.3",
49
- "@elementor/events": "4.3.3",
50
- "@elementor/http-client": "4.3.3",
51
- "@elementor/icons": "~1.77.0",
52
- "@elementor/locations": "4.3.3",
53
- "@elementor/session": "4.3.3",
54
- "@elementor/store": "4.3.3",
55
- "@elementor/ui": "1.37.5"
42
+ "@elementor/editor": "4.3.5",
43
+ "@elementor/editor-mcp": "4.3.5",
44
+ "@elementor/editor-app-bar": "4.3.5",
45
+ "@elementor/editor-elements": "4.3.5",
46
+ "@elementor/editor-floating-panels": "4.3.5",
47
+ "@elementor/editor-notifications": "4.3.5",
48
+ "@elementor/editor-props": "4.3.5",
49
+ "@elementor/editor-styles": "4.3.5",
50
+ "@elementor/editor-v1-adapters": "4.3.5",
51
+ "@elementor/events": "4.3.5",
52
+ "@elementor/http-client": "4.3.5",
53
+ "@elementor/icons": "~1.78.1",
54
+ "@elementor/locations": "4.3.5",
55
+ "@elementor/session": "4.3.5",
56
+ "@elementor/store": "4.3.5",
57
+ "@elementor/ui": "1.41.1"
56
58
  },
57
59
  "peerDependencies": {
58
60
  "@wordpress/i18n": "^5.13.0",
@@ -4,13 +4,16 @@ import { type PageContextResponse } from '../types';
4
4
  import { isNonceInvalidError, refreshAuditsNonce } from '../utils/session-expiration';
5
5
  import { getWindowConfig } from '../utils/window-config';
6
6
 
7
- export async function fetchPageContext( documentId: number, attachmentIds: number[] ): Promise< PageContextResponse > {
8
- return requestPageContext( documentId, attachmentIds, true );
7
+ export async function fetchPageContext(
8
+ documentId: number,
9
+ imageSizeRequests: string[]
10
+ ): Promise< PageContextResponse > {
11
+ return requestPageContext( documentId, imageSizeRequests, true );
9
12
  }
10
13
 
11
14
  async function requestPageContext(
12
15
  documentId: number,
13
- attachmentIds: number[],
16
+ imageSizeRequests: string[],
14
17
  allowNonceRetry: boolean
15
18
  ): Promise< PageContextResponse > {
16
19
  const { restNamespace, nonce } = getWindowConfig();
@@ -20,7 +23,7 @@ async function requestPageContext(
20
23
  const response = await httpService().get< PageContextResponse >( url, {
21
24
  params: {
22
25
  document_id: documentId,
23
- attachment_ids: attachmentIds,
26
+ image_size_requests: imageSizeRequests,
24
27
  },
25
28
  headers: { 'X-WP-Nonce': nonce },
26
29
  } );
@@ -33,6 +36,6 @@ async function requestPageContext(
33
36
 
34
37
  await refreshAuditsNonce();
35
38
 
36
- return requestPageContext( documentId, attachmentIds, false );
39
+ return requestPageContext( documentId, imageSizeRequests, false );
37
40
  }
38
41
  }
@@ -0,0 +1,39 @@
1
+ import { __ } from '@wordpress/i18n';
2
+
3
+ import { type Audit } from '../types';
4
+
5
+ export const audit: Audit = {
6
+ id: 'audits/accessibility-statement',
7
+ title: __( 'Create an accessibility statement', 'elementor' ),
8
+ description: __(
9
+ 'An accessibility statement showcases your efforts to create an inclusive online space, highlighting helpful features and a commitment to accessibility.',
10
+ 'elementor'
11
+ ),
12
+ fixHint: __( 'Use Ally to create and publish an accessibility statement for your site.', 'elementor' ),
13
+ categories: [ 'accessibility' ],
14
+ severity: 'info',
15
+ weight: 1,
16
+ evaluate: ( ctx ) => {
17
+ const isReady = ctx.pageContext.ally_plugin_active;
18
+
19
+ if ( isReady && ctx.pageContext.ally_accessibility_statement_created ) {
20
+ return { status: 'pass' };
21
+ }
22
+
23
+ return {
24
+ status: 'fail',
25
+ violations: [
26
+ {
27
+ auditId: audit.id,
28
+ label: __( 'No accessibility statement has been created for this site.', 'elementor' ),
29
+ externalUrl: isReady
30
+ ? ctx.pageContext.ally_accessibility_statement_url
31
+ : ctx.pageContext.ally_plugin_url,
32
+ ctaLabel: __( 'Create', 'elementor' ),
33
+ secondaryCtaLabel: __( 'Learn more', 'elementor' ),
34
+ secondaryCtaUrl: 'https://elementor.com/help/start-using-ally/#statement',
35
+ },
36
+ ],
37
+ };
38
+ },
39
+ };
@@ -0,0 +1,41 @@
1
+ import { __ } from '@wordpress/i18n';
2
+
3
+ import { type Audit } from '../types';
4
+ import { hasAccessibilityWidgetScript } from '../utils/scan-accessibility-widgets';
5
+
6
+ export const audit: Audit = {
7
+ id: 'audits/accessibility-widget',
8
+ title: __( 'Add an accessibility widget', 'elementor' ),
9
+ description: __( 'Make your site more inclusive by adding an accessibility widget.', 'elementor' ),
10
+ fixHint: __( 'Activate Ally’s accessibility widget to give visitors on-page accessibility tools.', 'elementor' ),
11
+ categories: [ 'accessibility' ],
12
+ severity: 'info',
13
+ weight: 1,
14
+ evaluate: ( ctx ) => {
15
+ if ( ! ctx.pageContext.frontend_url ) {
16
+ return { status: 'skipped', reason: __( 'Page is not published.', 'elementor' ) };
17
+ }
18
+
19
+ if ( ! ctx.renderedHtml ) {
20
+ return { status: 'skipped', reason: __( 'Could not fetch the published page.', 'elementor' ) };
21
+ }
22
+
23
+ if ( hasAccessibilityWidgetScript( ctx.renderedHtml ) ) {
24
+ return { status: 'pass' };
25
+ }
26
+
27
+ const isReady = ctx.pageContext.ally_plugin_active;
28
+
29
+ return {
30
+ status: 'fail',
31
+ violations: [
32
+ {
33
+ auditId: audit.id,
34
+ label: __( 'No accessibility widget is active on this site.', 'elementor' ),
35
+ externalUrl: isReady ? ctx.pageContext.ally_widget_settings_url : ctx.pageContext.ally_plugin_url,
36
+ ctaLabel: __( 'Activate', 'elementor' ),
37
+ },
38
+ ],
39
+ };
40
+ },
41
+ };
@@ -25,6 +25,7 @@ export const audit: Audit = {
25
25
  auditId: audit.id,
26
26
  label: __( 'Cookiez plugin is not installed or active.', 'elementor' ),
27
27
  externalUrl: ctx.pageContext.cookiez_plugin_url,
28
+ ctaLabel: __( 'Set up cookie consent', 'elementor' ),
28
29
  },
29
30
  ],
30
31
  };
@@ -1,7 +1,6 @@
1
1
  import { __ } from '@wordpress/i18n';
2
2
 
3
3
  import { type Audit } from '../types';
4
- import { fetchRenderedHtml } from '../utils/fetch-rendered-html';
5
4
  import { hasConsentDefaultCall, hasGoogleTracking } from '../utils/scan-consent-signals';
6
5
 
7
6
  export const audit: Audit = {
@@ -18,22 +17,20 @@ export const audit: Audit = {
18
17
  categories: [ 'compliance' ],
19
18
  severity: 'warning',
20
19
  weight: 1,
21
- evaluate: async ( ctx ) => {
20
+ evaluate: ( ctx ) => {
22
21
  if ( ! ctx.pageContext.frontend_url ) {
23
22
  return { status: 'skipped', reason: __( 'Page is not published.', 'elementor' ) };
24
23
  }
25
24
 
26
- const html = await fetchRenderedHtml( ctx.pageContext.frontend_url );
27
-
28
- if ( ! html ) {
25
+ if ( ! ctx.renderedHtml ) {
29
26
  return { status: 'skipped', reason: __( 'Could not fetch the published page.', 'elementor' ) };
30
27
  }
31
28
 
32
- if ( ! hasGoogleTracking( html ) ) {
29
+ if ( ! hasGoogleTracking( ctx.renderedHtml ) ) {
33
30
  return { status: 'skipped', reason: __( 'No Google tracking product detected.', 'elementor' ) };
34
31
  }
35
32
 
36
- if ( hasConsentDefaultCall( html ) ) {
33
+ if ( hasConsentDefaultCall( ctx.renderedHtml ) ) {
37
34
  return { status: 'pass' };
38
35
  }
39
36
 
@@ -1,8 +1,8 @@
1
1
  import { __ } from '@wordpress/i18n';
2
2
 
3
3
  import { type Audit, type AuditViolation } from '../types';
4
+ import { hasAltTextImages, walkAltTextImageSources } from '../utils/alt-text-image-sources';
4
5
  import { hasMeaningfulAlt, isImageSourcePresent } from '../utils/image-alt';
5
- import { hasPageImages, walkImageLikeSources } from '../utils/image-like-sources';
6
6
 
7
7
  export const audit: Audit = {
8
8
  id: 'audits/images-alt-text',
@@ -13,15 +13,16 @@ export const audit: Audit = {
13
13
  severity: 'error',
14
14
  weight: 10,
15
15
  evaluate: ( ctx ) => {
16
- if ( ! hasPageImages( ctx.elements.tree ) ) {
16
+ if ( ! hasAltTextImages( ctx.elements.tree ) ) {
17
17
  return { status: 'skipped', reason: __( 'No images', 'elementor' ) };
18
18
  }
19
19
 
20
+ const isReady = ctx.pageContext.ally_plugin_active;
20
21
  const violations: AuditViolation[] = [];
21
22
  const failedWidgetIds = new Set< string >();
22
23
  let missingAltImageCount = 0;
23
24
 
24
- walkImageLikeSources( ctx.elements.tree, ( { node, media } ) => {
25
+ walkAltTextImageSources( ctx.elements.tree, ( { node, media } ) => {
25
26
  if ( ! isImageSourcePresent( media ) || hasMeaningfulAlt( media, ctx.pageContext ) ) {
26
27
  return;
27
28
  }
@@ -35,7 +36,8 @@ export const audit: Audit = {
35
36
  elementId: node.id,
36
37
  targetHint: 'element-settings',
37
38
  label: __( 'Image is missing alt text.', 'elementor' ),
38
- externalUrl: ctx.pageContext.ally_plugin_url,
39
+ externalUrl: isReady ? ctx.pageContext.ally_scan_url : ctx.pageContext.ally_plugin_url,
40
+ ctaLabel: __( 'Fix with Ally', 'elementor' ),
39
41
  } );
40
42
  }
41
43
  } );
@@ -0,0 +1,66 @@
1
+ import { __ } from '@wordpress/i18n';
2
+
3
+ import { type Audit, type AuditViolation, type ElementSnapshotNode } from '../types';
4
+ import { walkAllImageSources } from '../utils/all-image-sources';
5
+ import { type ImageLikeMedia } from '../utils/image-like-sources';
6
+ import { buildImageSizeKey } from '../utils/image-size-key';
7
+
8
+ const LEGACY_RASTER_MIME_TYPES = new Set( [ 'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff' ] );
9
+
10
+ export const audit: Audit = {
11
+ id: 'audits/images-inefficient-format',
12
+ title: __( 'Inefficient image format', 'elementor' ),
13
+ description: __( "Image isn't served in a modern, efficient format like WebP or AVIF.", 'elementor' ),
14
+ fixHint: __( 'Convert the image to a modern format like WebP or AVIF, or enable image optimization.', 'elementor' ),
15
+ categories: [ 'performance' ],
16
+ severity: 'info',
17
+ weight: 1,
18
+ evaluate: ( ctx ) => {
19
+ const isReady = ctx.pageContext.image_optimization_plugin_active;
20
+ const offendingElementIds = new Set< string >();
21
+ let hasAnyImage = false;
22
+ let inefficientImageFormatCount = 0;
23
+
24
+ const evaluateSource = ( node: ElementSnapshotNode, media: ImageLikeMedia ) => {
25
+ if ( media.id || media.url ) {
26
+ hasAnyImage = true;
27
+ }
28
+
29
+ const id = media.id;
30
+
31
+ if ( ! id ) {
32
+ return;
33
+ }
34
+
35
+ const mime = ctx.pageContext.image_sizes[ buildImageSizeKey( { id, size: media.size } ) ]?.mime;
36
+
37
+ if ( ! mime || ! LEGACY_RASTER_MIME_TYPES.has( mime ) ) {
38
+ return;
39
+ }
40
+
41
+ inefficientImageFormatCount++;
42
+ offendingElementIds.add( node.id );
43
+ };
44
+
45
+ walkAllImageSources( ctx.elements.tree, ( { node, media } ) => evaluateSource( node, media ) );
46
+
47
+ if ( ! hasAnyImage ) {
48
+ return { status: 'skipped', reason: __( 'No images', 'elementor' ) };
49
+ }
50
+
51
+ const violations: AuditViolation[] = Array.from( offendingElementIds ).map( ( elementId ) => ( {
52
+ auditId: audit.id,
53
+ elementId,
54
+ targetHint: 'element-settings' as const,
55
+ label: __( "Image isn't served in a modern format like WebP or AVIF.", 'elementor' ),
56
+ externalUrl: isReady
57
+ ? ctx.pageContext.image_optimization_settings_url
58
+ : ctx.pageContext.image_optimization_plugin_url,
59
+ ctaLabel: __( 'Optimize all', 'elementor' ),
60
+ } ) );
61
+
62
+ return violations.length === 0
63
+ ? { status: 'pass' }
64
+ : { status: 'fail', violations, metadata: { inefficientImageFormatCount } };
65
+ },
66
+ };
@@ -1,7 +1,9 @@
1
1
  import { __, sprintf } from '@wordpress/i18n';
2
2
 
3
- import { type Audit, type AuditViolation } from '../types';
4
- import { hasPageImages, walkImageLikeSources } from '../utils/image-like-sources';
3
+ import { type Audit, type AuditViolation, type ElementSnapshotNode } from '../types';
4
+ import { walkAllImageSources } from '../utils/all-image-sources';
5
+ import { type ImageLikeMedia } from '../utils/image-like-sources';
6
+ import { buildImageSizeKey } from '../utils/image-size-key';
5
7
 
6
8
  const SIZE_THRESHOLD_BYTES = 500 * 1024;
7
9
  const BYTES_PER_KB = 1024;
@@ -15,21 +17,23 @@ export const audit: Audit = {
15
17
  severity: 'warning',
16
18
  weight: 7,
17
19
  evaluate: ( ctx ) => {
18
- if ( ! hasPageImages( ctx.elements.tree ) ) {
19
- return { status: 'skipped', reason: __( 'No images', 'elementor' ) };
20
- }
21
-
20
+ const isReady = ctx.pageContext.image_optimization_plugin_active;
22
21
  const widgetMaxKb = new Map< string, number >();
23
22
  let oversizedImageCount = 0;
23
+ let hasAnyImage = false;
24
+
25
+ const evaluateSource = ( node: ElementSnapshotNode, media: ImageLikeMedia ) => {
26
+ if ( media.id || media.url ) {
27
+ hasAnyImage = true;
28
+ }
24
29
 
25
- walkImageLikeSources( ctx.elements.tree, ( { node, media } ) => {
26
30
  const id = media.id;
27
31
 
28
32
  if ( ! id ) {
29
33
  return;
30
34
  }
31
35
 
32
- const size = ctx.pageContext.image_sizes[ id ];
36
+ const size = ctx.pageContext.image_sizes[ buildImageSizeKey( { id, size: media.size } ) ];
33
37
 
34
38
  if ( ! size || size.filesize_bytes <= SIZE_THRESHOLD_BYTES ) {
35
39
  return;
@@ -40,7 +44,13 @@ export const audit: Audit = {
40
44
  const kb = Math.round( size.filesize_bytes / BYTES_PER_KB );
41
45
  const currentMax = widgetMaxKb.get( node.id ) ?? 0;
42
46
  widgetMaxKb.set( node.id, Math.max( currentMax, kb ) );
43
- } );
47
+ };
48
+
49
+ walkAllImageSources( ctx.elements.tree, ( { node, media } ) => evaluateSource( node, media ) );
50
+
51
+ if ( ! hasAnyImage ) {
52
+ return { status: 'skipped', reason: __( 'No images', 'elementor' ) };
53
+ }
44
54
 
45
55
  const violations: AuditViolation[] = Array.from( widgetMaxKb.entries() ).map( ( [ elementId, kb ] ) => ( {
46
56
  auditId: audit.id,
@@ -51,7 +61,10 @@ export const audit: Audit = {
51
61
  __( 'Image is %d KB (over 500 KB).', 'elementor' ),
52
62
  kb
53
63
  ),
54
- externalUrl: ctx.pageContext.image_optimization_plugin_url,
64
+ externalUrl: isReady
65
+ ? ctx.pageContext.image_optimization_settings_url
66
+ : ctx.pageContext.image_optimization_plugin_url,
67
+ ctaLabel: __( 'Optimize all', 'elementor' ),
55
68
  } ) );
56
69
 
57
70
  return violations.length === 0
@@ -35,6 +35,11 @@ export const audit: Audit = {
35
35
  elementId: node.id,
36
36
  targetHint: 'element-settings',
37
37
  label: __( 'Nested boxed container.', 'elementor' ),
38
+ angieFix: true,
39
+ angiePrompt: __(
40
+ 'Find containers that have nested containers. Containers can be deep-nested. Update the nested container styling so that when a parent container already uses a boxed setting, any inner container inside it is set to Full Width. Keep the first parent container boxed, remove all boxed styling from the nested inner containers, and preserve the inner container’s content, spacing, alignment, and hierarchy.',
41
+ 'elementor'
42
+ ),
38
43
  } );
39
44
  }
40
45
  } );
@@ -1,5 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { useState } from 'react';
3
+ import { useFloatingPanelZIndex } from '@elementor/editor-floating-panels';
3
4
  import { notify } from '@elementor/editor-notifications';
4
5
  import { isExperimentActive } from '@elementor/editor-v1-adapters';
5
6
  import { useMixpanel } from '@elementor/events';
@@ -21,6 +22,7 @@ import {
21
22
  import { __ } from '@wordpress/i18n';
22
23
 
23
24
  import {
25
+ AUDIT_PANEL_ID,
24
26
  FEEDBACK_CANCELLED_EVENT,
25
27
  FEEDBACK_CLICKED_EVENT,
26
28
  FEEDBACK_CLOSED_EVENT,
@@ -58,6 +60,7 @@ export default function AuditFeedback() {
58
60
  const [ feedbackText, setFeedbackText ] = useState( '' );
59
61
  const [ isSubmitting, setIsSubmitting ] = useState( false );
60
62
  const { dispatchEvent: trackEvent = ( ...args: unknown[] ) => void args } = useMixpanel();
63
+ const panelZIndex = useFloatingPanelZIndex( AUDIT_PANEL_ID );
61
64
 
62
65
  if ( ! isExperimentActive( FEEDBACK_EXPERIMENT_NAME ) ) {
63
66
  return null;
@@ -117,7 +120,7 @@ export default function AuditFeedback() {
117
120
 
118
121
  return (
119
122
  <>
120
- <Tooltip title={ triggerLabel } placement="top">
123
+ <Tooltip title={ triggerLabel } placement="top" PopperProps={ { sx: { zIndex: panelZIndex } } }>
121
124
  <IconButton size="small" aria-label={ triggerLabel } onClick={ handleOpen }>
122
125
  <MessageLinesIcon fontSize="small" />
123
126
  </IconButton>
@@ -1,27 +1,29 @@
1
1
  import * as React from 'react';
2
- import { getCurrentDocumentId } from '@elementor/editor-elements';
2
+ import { getHostDocumentId } from '@elementor/editor-elements';
3
3
  import { FloatingPanelBody, FloatingPanelFooter, FloatingPanelHeader } from '@elementor/editor-floating-panels';
4
- import { Box, Button, Typography } from '@elementor/ui';
4
+ import { Box, Typography } from '@elementor/ui';
5
5
  import { __ } from '@wordpress/i18n';
6
6
 
7
+ import { AUDIT_PANEL_ID } from '../constants';
7
8
  import { useAuditReport } from '../hooks/use-audit-report';
8
9
  import AuditFeedback from './audit-feedback';
9
10
  import ErrorPage from './pages/error-page';
10
11
  import LoadingPage from './pages/loading-page';
11
12
  import WelcomePage from './pages/welcome-page';
12
13
  import ReportShell from './report-shell';
14
+ import RescanButton from './rescan-button';
13
15
 
14
16
  export default function AuditPanel() {
15
- const { status, report, error, run } = useAuditReport();
17
+ const { status, report, error, isStale, run } = useAuditReport();
16
18
 
17
- const currentDocumentId = getCurrentDocumentId() ?? 0;
18
- const onRun = () => run( currentDocumentId );
19
+ const hostDocumentId = getHostDocumentId() ?? 0;
20
+ const onRun = () => run( hostDocumentId );
19
21
  const lastScanLabel = report ? new Date( report.runAt ).toLocaleTimeString() : null;
20
22
 
21
23
  return (
22
24
  <>
23
25
  <FloatingPanelHeader
24
- panelId="audit-panel"
26
+ panelId={ AUDIT_PANEL_ID }
25
27
  title={ __( 'Page Audit', 'elementor' ) }
26
28
  badge={ __( 'Beta', 'elementor' ) }
27
29
  titleVariant="subtitle2"
@@ -47,14 +49,12 @@ export default function AuditPanel() {
47
49
  <Box sx={ { flex: 1 } } />
48
50
  ) }
49
51
  { lastScanLabel && <AuditFeedback /> }
50
- <Button
51
- variant="contained"
52
- size="small"
52
+ <RescanButton
53
+ hasScanned={ Boolean( lastScanLabel ) }
54
+ isStale={ isStale }
55
+ disabled={ status === 'loading' || hostDocumentId === 0 }
53
56
  onClick={ onRun }
54
- disabled={ status === 'loading' || currentDocumentId === 0 }
55
- >
56
- { lastScanLabel ? __( 'Re-scan', 'elementor' ) : __( 'Run page audit', 'elementor' ) }
57
- </Button>
57
+ />
58
58
  </FloatingPanelFooter>
59
59
  </>
60
60
  );
@@ -0,0 +1,22 @@
1
+ import * as React from 'react';
2
+ import { ShieldCheckIcon } from '@elementor/icons';
3
+ import { __useSelector as useSelector } from '@elementor/store';
4
+ import { Badge } from '@elementor/ui';
5
+
6
+ import { type GlobalState, selectIsStale } from '../store';
7
+
8
+ export default function AuditToggleIcon() {
9
+ const isStale = useSelector( ( state: GlobalState ) => selectIsStale( state ) );
10
+
11
+ return (
12
+ <Badge
13
+ variant="dot"
14
+ color="warning"
15
+ invisible={ ! isStale }
16
+ overlap="circular"
17
+ slotProps={ { badge: { 'data-testid': 'audit-stale-indicator' } } }
18
+ >
19
+ <ShieldCheckIcon />
20
+ </Badge>
21
+ );
22
+ }
@@ -1,16 +1,18 @@
1
1
  import * as React from 'react';
2
2
  import { isAngieAvailable, sendPromptToAngie } from '@elementor/editor-mcp';
3
3
  import { AngieIcon } from '@elementor/icons';
4
- import { IconButton, Tooltip } from '@elementor/ui';
4
+ import { Button, IconButton, Tooltip } from '@elementor/ui';
5
5
  import { __ } from '@wordpress/i18n';
6
6
 
7
7
  import { ANGIE_FIX_ENTRY_POINT, CREATE_WIDGET_EVENT } from '../constants';
8
8
 
9
9
  type Props = {
10
10
  prompt: string;
11
+ panelZIndex: number;
12
+ variant?: 'icon' | 'button';
11
13
  };
12
14
 
13
- export default function FixViolationWithAngie( { prompt }: Props ) {
15
+ export default function FixViolationWithAngie( { prompt, panelZIndex, variant = 'icon' }: Props ) {
14
16
  const label = __( 'Fix with Angie', 'elementor' );
15
17
  const href = `#angie-prompt=${ encodeURIComponent( prompt ) }`;
16
18
 
@@ -33,16 +35,25 @@ export default function FixViolationWithAngie( { prompt }: Props ) {
33
35
  );
34
36
  };
35
37
 
36
- return (
37
- <Tooltip title={ label }>
38
- <IconButton
39
- className="violation-hover-icon"
38
+ if ( variant === 'button' ) {
39
+ return (
40
+ <Button
40
41
  component="a"
41
42
  href={ href }
42
43
  size="small"
43
- aria-label={ label }
44
+ color="secondary"
45
+ variant="outlined"
46
+ startIcon={ <AngieIcon fontSize="tiny" aria-hidden={ true } /> }
44
47
  onClick={ handleClick }
45
48
  >
49
+ { label }
50
+ </Button>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <Tooltip title={ label } placement="top" PopperProps={ { sx: { zIndex: panelZIndex } } }>
56
+ <IconButton component="a" href={ href } size="small" aria-label={ label } onClick={ handleClick }>
46
57
  <AngieIcon fontSize="tiny" />
47
58
  </IconButton>
48
59
  </Tooltip>
@@ -3,6 +3,7 @@ import { Box } from '@elementor/ui';
3
3
  import { __ } from '@wordpress/i18n';
4
4
 
5
5
  import { CATEGORY_LABELS } from '../../constants';
6
+ import { useSingleExpandedCard } from '../../hooks/use-single-expanded-card';
6
7
  import { type AuditCategory, type PageAuditReport } from '../../types';
7
8
  import { partitionAuditResults } from '../../utils/audit-status-summary';
8
9
  import { CATEGORY_ICONS } from '../category-icons';
@@ -18,7 +19,8 @@ type Props = {
18
19
 
19
20
  export default function CategoryPage( { category, report, onBack }: Props ) {
20
21
  const Icon = CATEGORY_ICONS[ category ];
21
- const { failed, passed, totalViolations } = partitionAuditResults( report, { category } );
22
+ const { failed, passed, skipped, totalViolations } = partitionAuditResults( report, { category } );
23
+ const { expandedId, toggle } = useSingleExpandedCard();
22
24
 
23
25
  return (
24
26
  <>
@@ -36,12 +38,34 @@ export default function CategoryPage( { category, report, onBack }: Props ) {
36
38
  defaultExpanded
37
39
  >
38
40
  { failed.map( ( r ) => (
39
- <ViolationRow key={ r.audit.id } audit={ r.audit } violations={ r.result.violations } />
41
+ <ViolationRow
42
+ key={ r.audit.id }
43
+ audit={ r.audit }
44
+ violations={ r.result.violations }
45
+ expanded={ expandedId === r.audit.id }
46
+ onToggleExpand={ () => toggle( r.audit.id ) }
47
+ />
40
48
  ) ) }
41
49
  </StatusSection>
42
50
  <StatusSection label={ __( 'Passed audits', 'elementor' ) } count={ passed.length } color="success">
43
51
  { passed.map( ( r ) => (
44
- <ViolationRow key={ r.audit.id } audit={ r.audit } />
52
+ <ViolationRow
53
+ key={ r.audit.id }
54
+ audit={ r.audit }
55
+ expanded={ expandedId === r.audit.id }
56
+ onToggleExpand={ () => toggle( r.audit.id ) }
57
+ />
58
+ ) ) }
59
+ </StatusSection>
60
+ <StatusSection label={ __( 'Skipped audits', 'elementor' ) } count={ skipped.length } color="default">
61
+ { skipped.map( ( r ) => (
62
+ <ViolationRow
63
+ key={ r.audit.id }
64
+ audit={ r.audit }
65
+ skipReason={ r.result.reason }
66
+ expanded={ expandedId === r.audit.id }
67
+ onToggleExpand={ () => toggle( r.audit.id ) }
68
+ />
45
69
  ) ) }
46
70
  </StatusSection>
47
71
  </Box>