chrome-devtools-frontend 1.0.1603822 → 1.0.1605219

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 (89) hide show
  1. package/AUTHORS +1 -0
  2. package/front_end/Tests.js +23 -7
  3. package/front_end/core/host/AidaClient.ts +153 -525
  4. package/front_end/core/host/AidaClientTypes.ts +470 -0
  5. package/front_end/core/host/AidaGcaTranslation.ts +231 -122
  6. package/front_end/core/host/GcaClient.ts +112 -0
  7. package/front_end/core/host/GcaTypes.ts +107 -155
  8. package/front_end/core/host/UserMetrics.ts +0 -1
  9. package/front_end/core/host/host.ts +2 -0
  10. package/front_end/core/protocol_client/DevToolsCDPConnection.ts +1 -1
  11. package/front_end/core/root/ExperimentNames.ts +0 -1
  12. package/front_end/core/root/Runtime.ts +5 -0
  13. package/front_end/core/sdk/AutofillModel.ts +3 -2
  14. package/front_end/core/sdk/CSSModel.ts +3 -4
  15. package/front_end/core/sdk/CSSProperty.ts +1 -1
  16. package/front_end/core/sdk/ConsoleModel.ts +15 -13
  17. package/front_end/core/sdk/CookieModel.ts +5 -4
  18. package/front_end/core/sdk/DOMDebuggerModel.ts +20 -14
  19. package/front_end/core/sdk/DOMModel.ts +24 -22
  20. package/front_end/core/sdk/DebuggerModel.ts +21 -36
  21. package/front_end/core/sdk/EmulationModel.ts +21 -23
  22. package/front_end/core/sdk/EventBreakpointsModel.ts +16 -6
  23. package/front_end/core/sdk/HeapProfilerModel.ts +4 -7
  24. package/front_end/core/sdk/IsolateManager.ts +12 -7
  25. package/front_end/core/sdk/NetworkManager.ts +35 -28
  26. package/front_end/core/sdk/OverlayModel.ts +10 -10
  27. package/front_end/core/sdk/PreloadingModel.ts +3 -4
  28. package/front_end/core/sdk/ServiceWorkerManager.ts +2 -3
  29. package/front_end/core/sdk/SourceMapScopesInfo.ts +93 -63
  30. package/front_end/entrypoints/formatter_worker/FormatterWorker.ts +1 -3
  31. package/front_end/entrypoints/heap_snapshot_worker/AllocationProfile.ts +22 -48
  32. package/front_end/entrypoints/heap_snapshot_worker/HeapSnapshot.ts +1 -1
  33. package/front_end/entrypoints/main/MainImpl.ts +0 -4
  34. package/front_end/generated/InspectorBackendCommands.ts +4 -0
  35. package/front_end/generated/SupportedCSSProperties.js +68 -2
  36. package/front_end/generated/protocol-mapping.d.ts +7 -0
  37. package/front_end/generated/protocol-proxy-api.d.ts +14 -0
  38. package/front_end/generated/protocol.ts +22 -0
  39. package/front_end/models/ai_assistance/agents/PerformanceAgent.ts +31 -17
  40. package/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.snapshot.txt +1 -1
  41. package/front_end/models/bindings/TempFile.ts +1 -4
  42. package/front_end/models/emulation/DeviceModeModel.ts +21 -26
  43. package/front_end/models/extensions/ExtensionAPI.ts +1 -1
  44. package/front_end/models/heap_snapshot_model/HeapSnapshotModel.ts +34 -47
  45. package/front_end/models/javascript_metadata/NativeFunctions.js +6 -6
  46. package/front_end/models/trace/insights/LCPDiscovery.ts +14 -6
  47. package/front_end/panels/ai_assistance/AiAssistancePanel.ts +85 -31
  48. package/front_end/panels/ai_assistance/components/ChatMessage.ts +50 -40
  49. package/front_end/panels/ai_assistance/components/ChatView.ts +3 -2
  50. package/front_end/panels/ai_assistance/components/WalkthroughView.ts +35 -7
  51. package/front_end/panels/ai_assistance/components/chatMessage.css +5 -1
  52. package/front_end/panels/application/ApplicationPanelSidebar.ts +6 -0
  53. package/front_end/panels/application/IndexedDBViews.ts +0 -9
  54. package/front_end/panels/application/WebMCPTreeElement.ts +35 -0
  55. package/front_end/panels/application/WebMCPView.ts +118 -0
  56. package/front_end/panels/application/application.ts +4 -0
  57. package/front_end/panels/application/webMCPView.css +65 -0
  58. package/front_end/panels/common/aiCodeCompletionSummaryToolbar.css +1 -1
  59. package/front_end/panels/console/consoleView.css +4 -0
  60. package/front_end/panels/elements/ElementsTreeElement.ts +91 -13
  61. package/front_end/panels/elements/StylesAiCodeCompletionProvider.ts +23 -2
  62. package/front_end/panels/elements/StylesSidebarPane.ts +85 -20
  63. package/front_end/panels/elements/elementsPanel.css +14 -0
  64. package/front_end/panels/elements/elementsTreeOutline.css +18 -0
  65. package/front_end/panels/network/NetworkLogViewColumns.ts +3 -3
  66. package/front_end/panels/profiler/HeapSnapshotDataGrids.ts +15 -19
  67. package/front_end/panels/profiler/HeapSnapshotGridNodes.ts +4 -2
  68. package/front_end/panels/profiler/HeapSnapshotProxy.ts +12 -16
  69. package/front_end/panels/profiler/HeapSnapshotView.ts +3 -10
  70. package/front_end/panels/profiler/ProfilesPanel.ts +5 -7
  71. package/front_end/panels/recorder/components/recordingView.css +0 -6
  72. package/front_end/panels/sensors/SensorsView.ts +28 -61
  73. package/front_end/panels/timeline/EventsTimelineTreeView.ts +2 -2
  74. package/front_end/panels/timeline/ThirdPartyTreeView.ts +35 -36
  75. package/front_end/panels/timeline/TimelineDetailsView.ts +47 -41
  76. package/front_end/panels/timeline/TimelinePanel.ts +1 -3
  77. package/front_end/panels/timeline/TimelineTreeView.ts +13 -13
  78. package/front_end/panels/timeline/components/LiveMetricsView.ts +852 -824
  79. package/front_end/panels/timeline/components/liveMetricsView.css +4 -0
  80. package/front_end/panels/timeline/thirdPartyTreeView.css +2 -2
  81. package/front_end/third_party/chromium/README.chromium +1 -1
  82. package/front_end/ui/components/buttons/button.css +2 -2
  83. package/front_end/ui/components/text_editor/AiCodeCompletionProvider.ts +0 -11
  84. package/front_end/ui/legacy/InspectorDrawerView.ts +206 -0
  85. package/front_end/ui/legacy/InspectorView.ts +41 -91
  86. package/front_end/ui/legacy/ViewManager.ts +14 -5
  87. package/front_end/ui/legacy/inspectorDrawerTabbedPane.css +5 -0
  88. package/front_end/ui/visual_logging/KnownContextValues.ts +2 -1
  89. package/package.json +1 -1
@@ -21,8 +21,6 @@ import * as EmulationModel from '../../../models/emulation/emulation.js';
21
21
  import * as LiveMetrics from '../../../models/live-metrics/live-metrics.js';
22
22
  import * as Trace from '../../../models/trace/trace.js';
23
23
  import * as Buttons from '../../../ui/components/buttons/buttons.js';
24
- import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
25
- import * as LegacyWrapper from '../../../ui/components/legacy_wrapper/legacy_wrapper.js';
26
24
  import type * as Menus from '../../../ui/components/menus/menus.js';
27
25
  import * as RenderCoordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
28
26
  import type * as Settings from '../../../ui/components/settings/settings.js';
@@ -40,7 +38,7 @@ import metricValueStyles from './metricValueStyles.css.js';
40
38
  import {CLS_THRESHOLDS, INP_THRESHOLDS, renderMetricValue} from './Utils.js';
41
39
 
42
40
  const {html, nothing} = Lit;
43
- const {widget} = UI.Widget;
41
+ const {widget, widgetRef} = UI.Widget;
44
42
 
45
43
  type DeviceOption = CrUXManager.DeviceScope|'AUTO';
46
44
 
@@ -305,933 +303,979 @@ const UIStrings = {
305
303
  const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/LiveMetricsView.ts', UIStrings);
306
304
  const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
307
305
 
308
- export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableComponent {
309
- readonly #shadow = this.attachShadow({mode: 'open'});
310
-
311
- isNode = Root.Runtime.Runtime.isNode();
312
-
313
- #lcpValue?: LiveMetrics.LcpValue;
314
- #clsValue?: LiveMetrics.ClsValue;
315
- #inpValue?: LiveMetrics.InpValue;
316
- #interactions: LiveMetrics.InteractionMap = new Map();
317
- #layoutShifts: LiveMetrics.LayoutShift[] = [];
318
-
319
- #cruxManager = CrUXManager.CrUXManager.instance();
306
+ export interface ViewInput {
307
+ isNode: boolean;
308
+ lcpValue?: LiveMetrics.LcpValue;
309
+ clsValue?: LiveMetrics.ClsValue;
310
+ inpValue?: LiveMetrics.InpValue;
311
+ interactions: LiveMetrics.InteractionMap;
312
+ layoutShifts: LiveMetrics.LayoutShift[];
313
+ toggleRecordAction: UI.ActionRegistration.Action;
314
+ recordReloadAction: UI.ActionRegistration.Action;
315
+ cruxManager: CrUXManager.CrUXManager;
316
+ handlePageScopeSelected: (event: Menus.SelectMenu.SelectMenuItemSelectedEvent) => void;
317
+ handleDeviceOptionSelected: (event: Menus.SelectMenu.SelectMenuItemSelectedEvent) => void;
318
+ revealLayoutShiftCluster: (clusterIds: Set<LiveMetrics.LayoutShift['uniqueLayoutShiftId']>) => void;
319
+ revealInteraction: (interaction: LiveMetrics.Interaction) => void;
320
+ logExtraInteractionDetails: (interaction: LiveMetrics.Interaction) => void;
321
+ highlightedInteractionId?: string;
322
+ highlightedLayoutShiftClusterIds?: Set<string>;
323
+ }
320
324
 
321
- #toggleRecordAction: UI.ActionRegistration.Action;
322
- #recordReloadAction: UI.ActionRegistration.Action;
325
+ export interface ViewOutput {
326
+ shouldKeepInteractionsScrolledToBottom?: () => boolean;
327
+ keepInteractionsScrolledToBottom?: () => void;
328
+ shouldKeepLayoutShiftsScrolledToBottom?: () => boolean;
329
+ keepLayoutShiftsScrolledToBottom?: () => void;
330
+ }
323
331
 
324
- #logsEl?: LiveMetricsLogs;
325
- #tooltipContainerEl?: Element;
326
- #interactionsListEl?: HTMLElement;
327
- #layoutShiftsListEl?: HTMLElement;
328
- #listIsScrolling = false;
329
- #deviceModeModel = EmulationModel.DeviceModeModel.DeviceModeModel.tryInstance();
332
+ export type View = (input: ViewInput, output: ViewOutput, target: HTMLElement|DocumentFragment) => void;
333
+
334
+ function getLcpFieldPhases(cruxManager: CrUXManager.CrUXManager): LiveMetrics.LcpValue['phases']|null {
335
+ const ttfb =
336
+ cruxManager.getSelectedFieldMetricData('largest_contentful_paint_image_time_to_first_byte')?.percentiles?.p75;
337
+ const loadDelay =
338
+ cruxManager.getSelectedFieldMetricData('largest_contentful_paint_image_resource_load_delay')?.percentiles?.p75;
339
+ const loadDuration =
340
+ cruxManager.getSelectedFieldMetricData('largest_contentful_paint_image_resource_load_duration')?.percentiles?.p75;
341
+ const renderDelay =
342
+ cruxManager.getSelectedFieldMetricData('largest_contentful_paint_image_element_render_delay')?.percentiles?.p75;
343
+
344
+ if (typeof ttfb !== 'number' || typeof loadDelay !== 'number' || typeof loadDuration !== 'number' ||
345
+ typeof renderDelay !== 'number') {
346
+ return null;
347
+ }
330
348
 
331
- constructor() {
332
- super();
349
+ return {
350
+ timeToFirstByte: Trace.Types.Timing.Milli(ttfb),
351
+ resourceLoadDelay: Trace.Types.Timing.Milli(loadDelay),
352
+ resourceLoadTime: Trace.Types.Timing.Milli(loadDuration),
353
+ elementRenderDelay: Trace.Types.Timing.Milli(renderDelay),
354
+ };
355
+ }
333
356
 
334
- this.#toggleRecordAction = UI.ActionRegistry.ActionRegistry.instance().getAction('timeline.toggle-recording');
335
- this.#recordReloadAction = UI.ActionRegistry.ActionRegistry.instance().getAction('timeline.record-reload');
357
+ function getNetworkRecTitle(cruxManager: CrUXManager.CrUXManager): string|null {
358
+ const response = cruxManager.getSelectedFieldMetricData('round_trip_time');
359
+ if (!response?.percentiles) {
360
+ return null;
336
361
  }
337
362
 
338
- #onMetricStatus(event: {data: LiveMetrics.StatusEvent}): void {
339
- this.#lcpValue = event.data.lcp;
340
- this.#clsValue = event.data.cls;
341
- this.#inpValue = event.data.inp;
342
-
343
- const hasNewLS = this.#layoutShifts.length < event.data.layoutShifts.length;
344
- this.#layoutShifts = [...event.data.layoutShifts];
363
+ const rtt = Number(response.percentiles.p75);
364
+ if (!Number.isFinite(rtt)) {
365
+ return null;
366
+ }
345
367
 
346
- const hasNewInteraction = this.#interactions.size < event.data.interactions.size;
347
- this.#interactions = new Map(event.data.interactions);
368
+ if (rtt < RTT_MINIMUM) {
369
+ return i18nString(UIStrings.tryDisablingThrottling);
370
+ }
348
371
 
349
- const renderPromise = ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
372
+ const conditions = SDK.NetworkManager.getRecommendedNetworkPreset(rtt);
373
+ if (!conditions) {
374
+ return null;
375
+ }
350
376
 
351
- if (hasNewInteraction && this.#interactionsListEl) {
352
- this.#keepScrolledToBottom(renderPromise, this.#interactionsListEl);
353
- }
377
+ const title = typeof conditions.title === 'function' ? conditions.title() : conditions.title;
378
+ return i18nString(UIStrings.tryUsingThrottling, {PH1: title});
379
+ }
354
380
 
355
- if (hasNewLS && this.#layoutShiftsListEl) {
356
- this.#keepScrolledToBottom(renderPromise, this.#layoutShiftsListEl);
357
- }
381
+ function getDeviceRec(cruxManager: CrUXManager.CrUXManager): string|null {
382
+ // `form_factors` metric is only populated if CrUX data is fetched for all devices.
383
+ const fractions =
384
+ cruxManager.getFieldResponse(cruxManager.fieldPageScope, 'ALL')?.record.metrics.form_factors?.fractions;
385
+ if (!fractions) {
386
+ return null;
358
387
  }
359
388
 
360
- #keepScrolledToBottom(renderPromise: Promise<void>, listEl: HTMLElement): void {
361
- if (!listEl.checkVisibility()) {
362
- return;
363
- }
389
+ return i18nString(UIStrings.percentDevices, {
390
+ PH1: Math.round(fractions.phone * 100),
391
+ PH2: Math.round(fractions.desktop * 100),
392
+ });
393
+ }
364
394
 
365
- const isAtBottom = Math.abs(listEl.scrollHeight - listEl.clientHeight - listEl.scrollTop) <= 1;
395
+ function getPageScopeLabel(cruxManager: CrUXManager.CrUXManager, pageScope: CrUXManager.PageScope): string {
396
+ const key = cruxManager.pageResult?.[`${pageScope}-ALL` as const]?.record.key[pageScope];
397
+ if (key) {
398
+ return pageScope === 'url' ? i18nString(UIStrings.urlOptionWithKey, {PH1: key}) :
399
+ i18nString(UIStrings.originOptionWithKey, {PH1: key});
400
+ }
366
401
 
367
- // We shouldn't scroll to the bottom if the list wasn't already at the bottom.
368
- // However, if a new item appears while the animation for a previous item is still going,
369
- // then we should "finish" the scroll by sending another scroll command even if the scroll position
370
- // the element hasn't scrolled all the way to the bottom yet.
371
- if (!isAtBottom && !this.#listIsScrolling) {
372
- return;
373
- }
402
+ const baseLabel = pageScope === 'url' ? i18nString(UIStrings.urlOption) : i18nString(UIStrings.originOption);
403
+ return i18nString(UIStrings.needsDataOption, {PH1: baseLabel});
404
+ }
374
405
 
375
- void renderPromise.then(() => {
376
- requestAnimationFrame(() => {
377
- this.#listIsScrolling = true;
378
- listEl.addEventListener('scrollend', () => {
379
- this.#listIsScrolling = false;
380
- }, {once: true});
381
- listEl.scrollTo({top: listEl.scrollHeight, behavior: 'smooth'});
382
- });
383
- });
406
+ function getDeviceScopeDisplayName(deviceScope: CrUXManager.DeviceScope): string {
407
+ switch (deviceScope) {
408
+ case 'ALL':
409
+ return i18nString(UIStrings.allDevices);
410
+ case 'DESKTOP':
411
+ return i18nString(UIStrings.desktop);
412
+ case 'PHONE':
413
+ return i18nString(UIStrings.mobile);
414
+ case 'TABLET':
415
+ return i18nString(UIStrings.tablet);
384
416
  }
417
+ }
385
418
 
386
- #onFieldDataChanged(): void {
387
- void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
419
+ function getLabelForDeviceOption(cruxManager: CrUXManager.CrUXManager, deviceOption: DeviceOption): string {
420
+ let baseLabel;
421
+ if (deviceOption === 'AUTO') {
422
+ const deviceScope = cruxManager.resolveDeviceOptionToScope(deviceOption);
423
+ const deviceScopeLabel = getDeviceScopeDisplayName(deviceScope);
424
+ baseLabel = i18nString(UIStrings.auto, {PH1: deviceScopeLabel});
425
+ } else {
426
+ baseLabel = getDeviceScopeDisplayName(deviceOption);
388
427
  }
389
428
 
390
- #onEmulationChanged(): void {
391
- void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
429
+ if (!cruxManager.pageResult) {
430
+ return i18nString(UIStrings.loadingOption, {PH1: baseLabel});
392
431
  }
393
432
 
394
- async #refreshFieldDataForCurrentPage(): Promise<void> {
395
- if (!this.isNode) {
396
- await this.#cruxManager.refresh();
397
- }
398
- void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
433
+ const result = cruxManager.getSelectedFieldResponse();
434
+ if (!result) {
435
+ return i18nString(UIStrings.needsDataOption, {PH1: baseLabel});
399
436
  }
400
437
 
401
- connectedCallback(): void {
402
- const liveMetrics = LiveMetrics.LiveMetrics.instance();
403
- liveMetrics.addEventListener(LiveMetrics.Events.STATUS, this.#onMetricStatus, this);
438
+ return baseLabel;
439
+ }
404
440
 
405
- const cruxManager = CrUXManager.CrUXManager.instance();
406
- cruxManager.addEventListener(CrUXManager.Events.FIELD_DATA_CHANGED, this.#onFieldDataChanged, this);
441
+ function getCollectionPeriodRange(cruxManager: CrUXManager.CrUXManager): string|null {
442
+ const selectedResponse = cruxManager.getSelectedFieldResponse();
443
+ if (!selectedResponse) {
444
+ return null;
445
+ }
407
446
 
408
- this.#deviceModeModel?.addEventListener(
409
- EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
447
+ const {firstDate, lastDate} = selectedResponse.record.collectionPeriod;
448
+
449
+ const formattedFirstDate = new Date(
450
+ firstDate.year,
451
+ // CrUX month is 1-indexed but `Date` month is 0-indexed
452
+ firstDate.month - 1,
453
+ firstDate.day,
454
+ );
455
+ const formattedLastDate = new Date(
456
+ lastDate.year,
457
+ // CrUX month is 1-indexed but `Date` month is 0-indexed
458
+ lastDate.month - 1,
459
+ lastDate.day,
460
+ );
461
+
462
+ const options: Intl.DateTimeFormatOptions = {
463
+ year: 'numeric',
464
+ month: 'short',
465
+ day: 'numeric',
466
+ };
410
467
 
411
- if (cruxManager.getConfigSetting().get().enabled) {
412
- void this.#refreshFieldDataForCurrentPage();
468
+ return i18nString(UIStrings.dateRange, {
469
+ PH1: formattedFirstDate.toLocaleDateString(undefined, options),
470
+ PH2: formattedLastDate.toLocaleDateString(undefined, options),
471
+ });
472
+ }
473
+
474
+ function createMetricCardRef(cardData: Omit<MetricCardData, 'tooltipContainer'>):
475
+ ReturnType<typeof Lit.Directives.ref> {
476
+ return Lit.Directives.ref(el => {
477
+ if (el instanceof HTMLElement) {
478
+ (el as HTMLElement & {data: MetricCardData}).data = {
479
+ ...cardData,
480
+ tooltipContainer: (el.closest('.metric-cards') as HTMLElement) || undefined,
481
+ };
413
482
  }
483
+ });
484
+ }
414
485
 
415
- this.#lcpValue = liveMetrics.lcpValue;
416
- this.#clsValue = liveMetrics.clsValue;
417
- this.#inpValue = liveMetrics.inpValue;
418
- this.#interactions = liveMetrics.interactions;
419
- this.#layoutShifts = liveMetrics.layoutShifts;
420
- void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
421
- }
486
+ function renderLcpCard(input: ViewInput): Lit.LitTemplate {
487
+ const fieldData = input.cruxManager.getSelectedFieldMetricData('largest_contentful_paint');
488
+ const nodeLink =
489
+ input.lcpValue?.nodeRef && PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(input.lcpValue?.nodeRef);
490
+ const phases = input.lcpValue?.phases;
491
+
492
+ const fieldPhases = getLcpFieldPhases(input.cruxManager);
493
+
494
+ // clang-format off
495
+ return html`
496
+ <devtools-metric-card ${createMetricCardRef({
497
+ metric: 'LCP',
498
+ localValue: input.lcpValue?.value,
499
+ fieldValue: fieldData?.percentiles?.p75,
500
+ histogram: fieldData?.histogram,
501
+ warnings: input.lcpValue?.warnings,
502
+ phases: phases && [
503
+ [i18nString(UIStrings.timeToFirstByte), phases.timeToFirstByte, fieldPhases?.timeToFirstByte],
504
+ [i18nString(UIStrings.resourceLoadDelay), phases.resourceLoadDelay, fieldPhases?.resourceLoadDelay],
505
+ [i18nString(UIStrings.resourceLoadDuration), phases.resourceLoadTime, fieldPhases?.resourceLoadTime],
506
+ [i18nString(UIStrings.elementRenderDelay), phases.elementRenderDelay, fieldPhases?.elementRenderDelay],
507
+ ],
508
+ })}>
509
+ ${nodeLink ? html`
510
+ <div class="related-info" slot="extra-info">
511
+ <span class="related-info-label">${i18nString(UIStrings.lcpElement)}</span>
512
+ <span class="related-info-link">
513
+ ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, {node: input.lcpValue?.nodeRef})}
514
+ </span>
515
+ </div>
516
+ `
517
+ : nothing}
518
+ </devtools-metric-card>
519
+ `;
520
+ // clang-format on
521
+ }
422
522
 
423
- disconnectedCallback(): void {
424
- LiveMetrics.LiveMetrics.instance().removeEventListener(LiveMetrics.Events.STATUS, this.#onMetricStatus, this);
523
+ function renderClsCard(input: ViewInput): Lit.LitTemplate {
524
+ const fieldData = input.cruxManager.getSelectedFieldMetricData('cumulative_layout_shift');
525
+
526
+ const clusterIds = new Set(input.clsValue?.clusterShiftIds || []);
527
+ const clusterIsVisible =
528
+ clusterIds.size > 0 && input.layoutShifts.some(layoutShift => clusterIds.has(layoutShift.uniqueLayoutShiftId));
529
+
530
+ // clang-format off
531
+ return html`
532
+ <devtools-metric-card ${createMetricCardRef({
533
+ metric: 'CLS',
534
+ localValue: input.clsValue?.value,
535
+ fieldValue: fieldData?.percentiles?.p75,
536
+ histogram: fieldData?.histogram,
537
+ warnings: input.clsValue?.warnings,
538
+ })}>
539
+ ${clusterIsVisible ? html`
540
+ <div class="related-info" slot="extra-info">
541
+ <span class="related-info-label">${i18nString(UIStrings.worstCluster)}</span>
542
+ <button
543
+ class="link-to-log"
544
+ title=${i18nString(UIStrings.showClsCluster)}
545
+ @click=${() => input.revealLayoutShiftCluster(clusterIds)}
546
+ jslog=${VisualLogging.action('timeline.landing.show-cls-cluster').track({click: true})}
547
+ >${i18nString(UIStrings.numShifts, {shiftCount: clusterIds.size})}</button>
548
+ </div>
549
+ ` : nothing}
550
+ </devtools-metric-card>
551
+ `;
552
+ // clang-format on
553
+ }
425
554
 
426
- const cruxManager = CrUXManager.CrUXManager.instance();
427
- cruxManager.removeEventListener(CrUXManager.Events.FIELD_DATA_CHANGED, this.#onFieldDataChanged, this);
555
+ function renderInpCard(input: ViewInput): Lit.LitTemplate {
556
+ const fieldData = input.cruxManager.getSelectedFieldMetricData('interaction_to_next_paint');
557
+ const phases = input.inpValue?.phases;
558
+ const interaction = input.inpValue && input.interactions.get(input.inpValue.interactionId);
559
+
560
+ // clang-format off
561
+ return html`
562
+ <devtools-metric-card ${createMetricCardRef({
563
+ metric: 'INP',
564
+ localValue: input.inpValue?.value,
565
+ fieldValue: fieldData?.percentiles?.p75,
566
+ histogram: fieldData?.histogram,
567
+ warnings: input.inpValue?.warnings,
568
+ phases: phases && [
569
+ [i18nString(UIStrings.inputDelay), phases.inputDelay],
570
+ [i18nString(UIStrings.processingDuration), phases.processingDuration],
571
+ [i18nString(UIStrings.presentationDelay), phases.presentationDelay],
572
+ ],
573
+ })}>
574
+ ${interaction ? html`
575
+ <div class="related-info" slot="extra-info">
576
+ <span class="related-info-label">${i18nString(UIStrings.inpInteractionLink)}</span>
577
+ <button
578
+ class="link-to-log"
579
+ title=${i18nString(UIStrings.showInpInteraction)}
580
+ @click=${() => input.revealInteraction(interaction)}
581
+ jslog=${VisualLogging.action('timeline.landing.show-inp-interaction').track({click: true})}
582
+ >${interaction.interactionType}</button>
583
+ </div>
584
+ ` : nothing}
585
+ </devtools-metric-card>
586
+ `;
587
+ // clang-format on
588
+ }
428
589
 
429
- this.#deviceModeModel?.removeEventListener(
430
- EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
590
+ function renderRecordAction(action: UI.ActionRegistration.Action): Lit.LitTemplate {
591
+ function onClick(): void {
592
+ void action.execute();
431
593
  }
432
594
 
433
- #getLcpFieldPhases(): LiveMetrics.LcpValue['phases']|null {
434
- const ttfb = this.#cruxManager.getSelectedFieldMetricData('largest_contentful_paint_image_time_to_first_byte')
435
- ?.percentiles?.p75;
436
- const loadDelay =
437
- this.#cruxManager.getSelectedFieldMetricData('largest_contentful_paint_image_resource_load_delay')
438
- ?.percentiles?.p75;
439
- const loadDuration =
440
- this.#cruxManager.getSelectedFieldMetricData('largest_contentful_paint_image_resource_load_duration')
441
- ?.percentiles?.p75;
442
- const renderDelay =
443
- this.#cruxManager.getSelectedFieldMetricData('largest_contentful_paint_image_element_render_delay')
444
- ?.percentiles?.p75;
445
-
446
- if (typeof ttfb !== 'number' || typeof loadDelay !== 'number' || typeof loadDuration !== 'number' ||
447
- typeof renderDelay !== 'number') {
448
- return null;
449
- }
595
+ // clang-format off
596
+ return html`
597
+ <div class="record-action">
598
+ <devtools-button @click=${onClick} .data=${{
599
+ variant: Buttons.Button.Variant.TEXT,
600
+ size: Buttons.Button.Size.REGULAR,
601
+ iconName: action.icon(),
602
+ title: action.title(),
603
+ jslogContext: action.id(),
604
+ } as Buttons.Button.ButtonData}>
605
+ ${action.title()}
606
+ </devtools-button>
607
+ <span class="shortcut-label">${UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutTitleForAction(action.id())}</span>
608
+ </div>
609
+ `;
610
+ // clang-format on
611
+ }
450
612
 
451
- return {
452
- timeToFirstByte: Trace.Types.Timing.Milli(ttfb),
453
- resourceLoadDelay: Trace.Types.Timing.Milli(loadDelay),
454
- resourceLoadTime: Trace.Types.Timing.Milli(loadDuration),
455
- elementRenderDelay: Trace.Types.Timing.Milli(renderDelay),
456
- };
457
- }
613
+ function renderRecordingSettings(input: ViewInput): Lit.LitTemplate {
614
+ const fieldEnabled = input.cruxManager.getConfigSetting().get().enabled;
615
+
616
+ const deviceRec = getDeviceRec(input.cruxManager) || i18nString(UIStrings.notEnoughData);
617
+ const networkRec = getNetworkRecTitle(input.cruxManager) || i18nString(UIStrings.notEnoughData);
618
+
619
+ const recs = PanelsCommon.ThrottlingUtils.getThrottlingRecommendations();
620
+
621
+ // clang-format off
622
+ return html`
623
+ <h3 class="card-title">${i18nString(UIStrings.environmentSettings)}</h3>
624
+ <div class="device-toolbar-description">${md(i18nString(UIStrings.useDeviceToolbar))}</div>
625
+ ${fieldEnabled ? html`
626
+ <ul class="environment-recs-list">
627
+ <li>${uiI18n.getFormatLocalizedStringTemplate(str_, UIStrings.device, {PH1: html`<span class="environment-rec">${deviceRec}</span>`})}</li>
628
+ <li>${uiI18n.getFormatLocalizedStringTemplate(str_, UIStrings.network, {PH1: html`<span class="environment-rec">${networkRec}</span>`})}</li>
629
+ </ul>
630
+ ` : nothing}
631
+ <div class="environment-option">
632
+ ${widget(CPUThrottlingSelector, {recommendedOption: recs.cpuOption})}
633
+ </div>
634
+ <div class="environment-option">
635
+ <devtools-network-throttling-selector .recommendedConditions=${recs.networkConditions}></devtools-network-throttling-selector>
636
+ </div>
637
+ <div class="environment-option">
638
+ <setting-checkbox
639
+ class="network-cache-setting"
640
+ .data=${{
641
+ setting: Common.Settings.Settings.instance().moduleSetting('cache-disabled'),
642
+ textOverride: i18nString(UIStrings.disableNetworkCache),
643
+ } as Settings.SettingCheckbox.SettingCheckboxData}
644
+ ></setting-checkbox>
645
+ </div>
646
+ `;
647
+ // clang-format on
648
+ }
458
649
 
459
- #renderLcpCard(): Lit.LitTemplate {
460
- const fieldData = this.#cruxManager.getSelectedFieldMetricData('largest_contentful_paint');
461
- const nodeLink =
462
- this.#lcpValue?.nodeRef && PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(this.#lcpValue?.nodeRef);
463
- const phases = this.#lcpValue?.phases;
464
-
465
- const fieldPhases = this.#getLcpFieldPhases();
466
-
467
- // clang-format off
468
- return html`
469
- <devtools-metric-card .data=${{
470
- metric: 'LCP',
471
- localValue: this.#lcpValue?.value,
472
- fieldValue: fieldData?.percentiles?.p75,
473
- histogram: fieldData?.histogram,
474
- tooltipContainer: this.#tooltipContainerEl,
475
- warnings: this.#lcpValue?.warnings,
476
- phases: phases && [
477
- [i18nString(UIStrings.timeToFirstByte), phases.timeToFirstByte, fieldPhases?.timeToFirstByte],
478
- [i18nString(UIStrings.resourceLoadDelay), phases.resourceLoadDelay, fieldPhases?.resourceLoadDelay],
479
- [i18nString(UIStrings.resourceLoadDuration), phases.resourceLoadTime, fieldPhases?.resourceLoadTime],
480
- [i18nString(UIStrings.elementRenderDelay), phases.elementRenderDelay, fieldPhases?.elementRenderDelay],
481
- ],
482
- } as MetricCardData}>
483
- ${nodeLink ? html`
484
- <div class="related-info" slot="extra-info">
485
- <span class="related-info-label">${i18nString(UIStrings.lcpElement)}</span>
486
- <span class="related-info-link">
487
- ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, {node: this.#lcpValue?.nodeRef})}
488
- </span>
489
- </div>
490
- `
491
- : nothing}
492
- </devtools-metric-card>
493
- `;
494
- // clang-format on
650
+ function renderPageScopeSetting(input: ViewInput): Lit.LitTemplate {
651
+ if (!input.cruxManager.getConfigSetting().get().enabled) {
652
+ return Lit.nothing;
495
653
  }
496
654
 
497
- #renderClsCard(): Lit.LitTemplate {
498
- const fieldData = this.#cruxManager.getSelectedFieldMetricData('cumulative_layout_shift');
499
-
500
- const clusterIds = new Set(this.#clsValue?.clusterShiftIds || []);
501
- const clusterIsVisible =
502
- clusterIds.size > 0 && this.#layoutShifts.some(layoutShift => clusterIds.has(layoutShift.uniqueLayoutShiftId));
503
-
504
- // clang-format off
505
- return html`
506
- <devtools-metric-card .data=${{
507
- metric: 'CLS',
508
- localValue: this.#clsValue?.value,
509
- fieldValue: fieldData?.percentiles?.p75,
510
- histogram: fieldData?.histogram,
511
- tooltipContainer: this.#tooltipContainerEl,
512
- warnings: this.#clsValue?.warnings,
513
- } as MetricCardData}>
514
- ${clusterIsVisible ? html`
515
- <div class="related-info" slot="extra-info">
516
- <span class="related-info-label">${i18nString(UIStrings.worstCluster)}</span>
517
- <button
518
- class="link-to-log"
519
- title=${i18nString(UIStrings.showClsCluster)}
520
- @click=${() => this.#revealLayoutShiftCluster(clusterIds)}
521
- jslog=${VisualLogging.action('timeline.landing.show-cls-cluster').track({click: true})}
522
- >${i18nString(UIStrings.numShifts, {shiftCount: clusterIds.size})}</button>
523
- </div>
524
- ` : nothing}
525
- </devtools-metric-card>
526
- `;
527
- // clang-format on
528
- }
655
+ const urlLabel = getPageScopeLabel(input.cruxManager, 'url');
656
+ const originLabel = getPageScopeLabel(input.cruxManager, 'origin');
657
+
658
+ const buttonTitle = input.cruxManager.fieldPageScope === 'url' ? urlLabel : originLabel;
659
+ const accessibleTitle = i18nString(UIStrings.showFieldDataForPage, {PH1: buttonTitle});
660
+
661
+ // If there is no data at all we should force users to switch pages or reconfigure CrUX.
662
+ const shouldDisable = !input.cruxManager.pageResult?.['url-ALL'] && !input.cruxManager.pageResult?.['origin-ALL'];
663
+
664
+ /* eslint-disable @devtools/no-deprecated-component-usages */
665
+ return html`
666
+ <devtools-select-menu
667
+ id="page-scope-select"
668
+ class="field-data-option"
669
+ @selectmenuselected=${input.handlePageScopeSelected}
670
+ .showDivider=${true}
671
+ .showArrow=${true}
672
+ .sideButton=${false}
673
+ .showSelectedItem=${true}
674
+ .buttonTitle=${buttonTitle}
675
+ .disabled=${shouldDisable}
676
+ title=${accessibleTitle}
677
+ >
678
+ <devtools-menu-item
679
+ .value=${'url'}
680
+ .selected=${input.cruxManager.fieldPageScope === 'url'}
681
+ >
682
+ ${urlLabel}
683
+ </devtools-menu-item>
684
+ <devtools-menu-item
685
+ .value=${'origin'}
686
+ .selected=${input.cruxManager.fieldPageScope === 'origin'}
687
+ >
688
+ ${originLabel}
689
+ </devtools-menu-item>
690
+ </devtools-select-menu>
691
+ `;
692
+ /* eslint-enable @devtools/no-deprecated-component-usages */
693
+ }
529
694
 
530
- #renderInpCard(): Lit.LitTemplate {
531
- const fieldData = this.#cruxManager.getSelectedFieldMetricData('interaction_to_next_paint');
532
- const phases = this.#inpValue?.phases;
533
- const interaction = this.#inpValue && this.#interactions.get(this.#inpValue.interactionId);
534
-
535
- // clang-format off
536
- return html`
537
- <devtools-metric-card .data=${{
538
- metric: 'INP',
539
- localValue: this.#inpValue?.value,
540
- fieldValue: fieldData?.percentiles?.p75,
541
- histogram: fieldData?.histogram,
542
- tooltipContainer: this.#tooltipContainerEl,
543
- warnings: this.#inpValue?.warnings,
544
- phases: phases && [
545
- [i18nString(UIStrings.inputDelay), phases.inputDelay],
546
- [i18nString(UIStrings.processingDuration), phases.processingDuration],
547
- [i18nString(UIStrings.presentationDelay), phases.presentationDelay],
548
- ],
549
- } as MetricCardData}>
550
- ${interaction ? html`
551
- <div class="related-info" slot="extra-info">
552
- <span class="related-info-label">${i18nString(UIStrings.inpInteractionLink)}</span>
553
- <button
554
- class="link-to-log"
555
- title=${i18nString(UIStrings.showInpInteraction)}
556
- @click=${() => this.#revealInteraction(interaction)}
557
- jslog=${VisualLogging.action('timeline.landing.show-inp-interaction').track({click: true})}
558
- >${interaction.interactionType}</button>
559
- </div>
560
- ` : nothing}
561
- </devtools-metric-card>
562
- `;
563
- // clang-format on
695
+ function renderDeviceScopeSetting(input: ViewInput): Lit.LitTemplate {
696
+ if (!input.cruxManager.getConfigSetting().get().enabled) {
697
+ return Lit.nothing;
564
698
  }
565
699
 
566
- #renderRecordAction(action: UI.ActionRegistration.Action): Lit.LitTemplate {
567
- function onClick(): void {
568
- void action.execute();
569
- }
700
+ // If there is no data at all we should force users to try adjusting the page scope
701
+ // before coming back to this option.
702
+ const shouldDisable = !input.cruxManager.getFieldResponse(input.cruxManager.fieldPageScope, 'ALL');
703
+
704
+ const currentDeviceLabel = getLabelForDeviceOption(input.cruxManager, input.cruxManager.fieldDeviceOption);
705
+
706
+ // clang-format off
707
+ /* eslint-disable @devtools/no-deprecated-component-usages */
708
+ return html`
709
+ <devtools-select-menu
710
+ id="device-scope-select"
711
+ class="field-data-option"
712
+ @selectmenuselected=${input.handleDeviceOptionSelected}
713
+ .showDivider=${true}
714
+ .showArrow=${true}
715
+ .sideButton=${false}
716
+ .showSelectedItem=${true}
717
+ .buttonTitle=${i18nString(UIStrings.device, {PH1: currentDeviceLabel})}
718
+ .disabled=${shouldDisable}
719
+ title=${i18nString(UIStrings.showFieldDataForDevice, {PH1: currentDeviceLabel})}
720
+ >
721
+ ${DEVICE_OPTION_LIST.map(deviceOption => {
722
+ return html`
723
+ <devtools-menu-item
724
+ .value=${deviceOption}
725
+ .selected=${input.cruxManager.fieldDeviceOption === deviceOption}
726
+ >
727
+ ${getLabelForDeviceOption(input.cruxManager, deviceOption)}
728
+ </devtools-menu-item>
729
+ `;
730
+ })}
731
+ </devtools-select-menu>
732
+ `;
733
+ /* eslint-enable @devtools/no-deprecated-component-usages */
734
+ // clang-format on
735
+ }
570
736
 
571
- // clang-format off
572
- return html`
573
- <div class="record-action">
574
- <devtools-button @click=${onClick} .data=${{
575
- variant: Buttons.Button.Variant.TEXT,
576
- size: Buttons.Button.Size.REGULAR,
577
- iconName: action.icon(),
578
- title: action.title(),
579
- jslogContext: action.id(),
580
- } as Buttons.Button.ButtonData}>
581
- ${action.title()}
582
- </devtools-button>
583
- <span class="shortcut-label">${UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutTitleForAction(action.id())}</span>
584
- </div>
585
- `;
586
- // clang-format on
737
+ function renderFieldDataHistoryLink(cruxManager: CrUXManager.CrUXManager): Lit.LitTemplate {
738
+ if (!cruxManager.getConfigSetting().get().enabled) {
739
+ return Lit.nothing;
740
+ }
741
+ const normalizedUrl = cruxManager.pageResult?.normalizedUrl;
742
+ if (!normalizedUrl) {
743
+ return Lit.nothing;
587
744
  }
745
+ const tmp = new URL('https://cruxvis.withgoogle.com/');
746
+ tmp.searchParams.set('view', 'cwvsummary');
747
+ tmp.searchParams.set('url', normalizedUrl);
748
+ // identifier must be 'origin' or 'url'.
749
+ const identifier = cruxManager.fieldPageScope;
750
+ tmp.searchParams.set('identifier', identifier);
751
+ // device must be one 'PHONE', 'DESKTOP', 'TABLET', or 'ALL'.
752
+ const device = cruxManager.getSelectedDeviceScope();
753
+ tmp.searchParams.set('device', device);
754
+ const cruxVis = `${tmp.origin}/#/${tmp.search}`;
755
+ return html`
756
+ (<devtools-link href=${cruxVis}
757
+ class="local-field-link"
758
+ title=${i18nString(UIStrings.fieldDataHistoryTooltip)}
759
+ >${i18nString(UIStrings.fieldDataHistoryLink)}</devtools-link>)
760
+ `;
761
+ }
588
762
 
589
- #getNetworkRecTitle(): string|null {
590
- const response = this.#cruxManager.getSelectedFieldMetricData('round_trip_time');
591
- if (!response?.percentiles) {
592
- return null;
593
- }
763
+ function renderCollectionPeriod(cruxManager: CrUXManager.CrUXManager): Lit.LitTemplate {
764
+ const range = getCollectionPeriodRange(cruxManager);
594
765
 
595
- const rtt = Number(response.percentiles.p75);
596
- if (!Number.isFinite(rtt)) {
597
- return null;
598
- }
766
+ const dateText = range || i18nString(UIStrings.notEnoughData);
599
767
 
600
- if (rtt < RTT_MINIMUM) {
601
- return i18nString(UIStrings.tryDisablingThrottling);
602
- }
768
+ const fieldDataHistoryLink = range ? renderFieldDataHistoryLink(cruxManager) : Lit.nothing;
603
769
 
604
- const conditions = SDK.NetworkManager.getRecommendedNetworkPreset(rtt);
605
- if (!conditions) {
606
- return null;
607
- }
608
-
609
- const title = typeof conditions.title === 'function' ? conditions.title() : conditions.title;
610
- return i18nString(UIStrings.tryUsingThrottling, {PH1: title});
611
- }
770
+ const warnings = cruxManager.pageResult?.warnings || [];
612
771
 
613
- #getDeviceRec(): string|null {
614
- // `form_factors` metric is only populated if CrUX data is fetched for all devices.
615
- const fractions = this.#cruxManager.getFieldResponse(this.#cruxManager.fieldPageScope, 'ALL')
616
- ?.record.metrics.form_factors?.fractions;
617
- if (!fractions) {
618
- return null;
619
- }
772
+ return html`
773
+ <div class="field-data-message">
774
+ <div>${uiI18n.getFormatLocalizedStringTemplate(str_, UIStrings.collectionPeriod, {
775
+ PH1: html`<span class="collection-period-range">${dateText}</span>`,
776
+ })} ${fieldDataHistoryLink}</div>
777
+ ${warnings.map(warning => html`
778
+ <div class="field-data-warning">${warning}</div>
779
+ `)}
780
+ </div>
781
+ `;
782
+ }
620
783
 
621
- return i18nString(UIStrings.percentDevices, {
622
- PH1: Math.round(fractions.phone * 100),
623
- PH2: Math.round(fractions.desktop * 100),
624
- });
784
+ function renderFieldDataMessage(cruxManager: CrUXManager.CrUXManager): Lit.LitTemplate {
785
+ if (cruxManager.getConfigSetting().get().enabled) {
786
+ return renderCollectionPeriod(cruxManager);
625
787
  }
626
788
 
627
- #renderRecordingSettings(): Lit.LitTemplate {
628
- const fieldEnabled = this.#cruxManager.getConfigSetting().get().enabled;
789
+ // clang-format off
790
+ return html`
791
+ <div class="field-data-message">
792
+ ${uiI18n.getFormatLocalizedStringTemplate(
793
+ str_,
794
+ UIStrings.seeHowYourLocalMetricsCompare,
795
+ { PH1: html`<devtools-link href="https://developer.chrome.com/docs/crux">${i18n.i18n.lockedString('Chrome UX Report')}</devtools-link>` },
796
+ )}
797
+ </div>
798
+ `;
799
+ // clang-format on
800
+ }
629
801
 
630
- const deviceRecEl = document.createElement('span');
631
- deviceRecEl.classList.add('environment-rec');
632
- deviceRecEl.textContent = this.#getDeviceRec() || i18nString(UIStrings.notEnoughData);
802
+ const listIsScrolling = new WeakMap<HTMLElement, boolean>();
633
803
 
634
- const networkRecEl = document.createElement('span');
635
- networkRecEl.classList.add('environment-rec');
636
- networkRecEl.textContent = this.#getNetworkRecTitle() || i18nString(UIStrings.notEnoughData);
804
+ function shouldKeepScrolledToBottom(listEl: HTMLElement): boolean {
805
+ if (!listEl.checkVisibility()) {
806
+ return false;
807
+ }
637
808
 
638
- const recs = PanelsCommon.ThrottlingUtils.getThrottlingRecommendations();
809
+ const isAtBottom = Math.abs(listEl.scrollHeight - listEl.clientHeight - listEl.scrollTop) <= 1;
639
810
 
640
- // clang-format off
641
- return html`
642
- <h3 class="card-title">${i18nString(UIStrings.environmentSettings)}</h3>
643
- <div class="device-toolbar-description">${md(i18nString(UIStrings.useDeviceToolbar))}</div>
644
- ${fieldEnabled ? html`
645
- <ul class="environment-recs-list">
646
- <li>${uiI18n.getFormatLocalizedString(str_, UIStrings.device, {PH1: deviceRecEl})}</li>
647
- <li>${uiI18n.getFormatLocalizedString(str_, UIStrings.network, {PH1: networkRecEl})}</li>
648
- </ul>
649
- ` : nothing}
650
- <div class="environment-option">
651
- ${widget(CPUThrottlingSelector, {recommendedOption: recs.cpuOption})}
652
- </div>
653
- <div class="environment-option">
654
- <devtools-network-throttling-selector .recommendedConditions=${recs.networkConditions}></devtools-network-throttling-selector>
655
- </div>
656
- <div class="environment-option">
657
- <setting-checkbox
658
- class="network-cache-setting"
659
- .data=${{
660
- setting: Common.Settings.Settings.instance().moduleSetting('cache-disabled'),
661
- textOverride: i18nString(UIStrings.disableNetworkCache),
662
- } as Settings.SettingCheckbox.SettingCheckboxData}
663
- ></setting-checkbox>
664
- </div>
665
- `;
666
- // clang-format on
667
- }
811
+ // We shouldn't scroll to the bottom if the list wasn't already at the bottom.
812
+ // However, if a new item appears while the animation for a previous item is still going,
813
+ // then we should "finish" the scroll by sending another scroll command even if the scroll position
814
+ // the element hasn't scrolled all the way to the bottom yet.
815
+ return isAtBottom || Boolean(listIsScrolling.get(listEl));
816
+ }
668
817
 
669
- #getPageScopeLabel(pageScope: CrUXManager.PageScope): string {
670
- const key = this.#cruxManager.pageResult?.[`${pageScope}-ALL`]?.record.key[pageScope];
671
- if (key) {
672
- return pageScope === 'url' ? i18nString(UIStrings.urlOptionWithKey, {PH1: key}) :
673
- i18nString(UIStrings.originOptionWithKey, {PH1: key});
674
- }
818
+ function keepScrolledToBottom(listEl: HTMLElement): void {
819
+ requestAnimationFrame(() => {
820
+ listIsScrolling.set(listEl, true);
821
+ listEl.addEventListener('scrollend', () => {
822
+ listIsScrolling.set(listEl, false);
823
+ }, {once: true});
824
+ listEl.scrollTo({top: listEl.scrollHeight, behavior: 'smooth'});
825
+ });
826
+ }
675
827
 
676
- const baseLabel = pageScope === 'url' ? i18nString(UIStrings.urlOption) : i18nString(UIStrings.originOption);
677
- return i18nString(UIStrings.needsDataOption, {PH1: baseLabel});
828
+ function renderInteractionsLog(input: ViewInput, output: ViewOutput): Lit.LitTemplate {
829
+ if (!input.interactions.size) {
830
+ return Lit.nothing;
678
831
  }
679
832
 
680
- #onPageScopeMenuItemSelected(event: Menus.SelectMenu.SelectMenuItemSelectedEvent): void {
681
- if (event.itemValue === 'url') {
682
- this.#cruxManager.fieldPageScope = 'url';
683
- } else {
684
- this.#cruxManager.fieldPageScope = 'origin';
685
- }
686
- void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
833
+ // clang-format off
834
+ return html`
835
+ <ol class="log"
836
+ slot="interactions-log-content"
837
+ ${Lit.Directives.ref(el => {
838
+ if (el instanceof HTMLElement) {
839
+ output.shouldKeepInteractionsScrolledToBottom = () => {
840
+ return shouldKeepScrolledToBottom(el);
841
+ };
842
+ output.keepInteractionsScrolledToBottom = () => {
843
+ keepScrolledToBottom(el);
844
+ };
845
+ }
846
+ })}
847
+ >
848
+ ${input.interactions.values().map(interaction => {
849
+ const metricValue = renderMetricValue(
850
+ 'timeline.landing.interaction-event-timing',
851
+ interaction.duration,
852
+ INP_THRESHOLDS,
853
+ v => i18n.TimeUtilities.preciseMillisToString(v),
854
+ {dim: true},
855
+ );
856
+
857
+ const isP98Excluded = input.inpValue && input.inpValue.value < interaction.duration;
858
+ const isInp = input.inpValue?.interactionId === interaction.interactionId;
859
+
860
+ return html`
861
+ <li id=${interaction.interactionId} class="log-item interaction" tabindex="-1">
862
+ <details>
863
+ <summary>
864
+ <span class="interaction-type">
865
+ ${interaction.interactionType} ${isInp ?
866
+ html`<span class="interaction-inp-chip" title=${i18nString(UIStrings.inpInteraction)}>INP</span>`
867
+ : nothing}
868
+ </span>
869
+ <span class="interaction-node">
870
+ ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, {node: interaction.nodeRef})}
871
+ </span>
872
+ ${isP98Excluded ? html`<devtools-icon
873
+ class="interaction-info"
874
+ name="info"
875
+ title=${i18nString(UIStrings.interactionExcluded)}
876
+ ></devtools-icon>` : nothing}
877
+ <span class="interaction-duration">${metricValue}</span>
878
+ </summary>
879
+ <div class="phase-table" role="table">
880
+ <div class="phase-table-row phase-table-header-row" role="row">
881
+ <div role="columnheader">${i18nString(UIStrings.phase)}</div>
882
+ <div role="columnheader">
883
+ ${interaction.longAnimationFrameTimings.length ? html`
884
+ <button
885
+ class="log-extra-details-button"
886
+ title=${i18nString(UIStrings.logToConsole)}
887
+ @click=${() => input.logExtraInteractionDetails(interaction)}
888
+ >${i18nString(UIStrings.duration)}</button>
889
+ ` : i18nString(UIStrings.duration)}
890
+ </div>
891
+ </div>
892
+ <div class="phase-table-row" role="row">
893
+ <div role="cell">${i18nString(UIStrings.inputDelay)}</div>
894
+ <div role="cell">${Math.round(interaction.phases.inputDelay)}</div>
895
+ </div>
896
+ <div class="phase-table-row" role="row">
897
+ <div role="cell">${i18nString(UIStrings.processingDuration)}</div>
898
+ <div role="cell">${Math.round(interaction.phases.processingDuration)}</div>
899
+ </div>
900
+ <div class="phase-table-row" role="row">
901
+ <div role="cell">${i18nString(UIStrings.presentationDelay)}</div>
902
+ <div role="cell">${Math.round(interaction.phases.presentationDelay)}</div>
903
+ </div>
904
+ </div>
905
+ </details>
906
+ </li>
907
+ `;
908
+ })}
909
+ </ol>
910
+ `;
911
+ // clang-format on
912
+ }
913
+
914
+ function renderLayoutShiftsLog(input: ViewInput, output: ViewOutput): Lit.LitTemplate {
915
+ if (!input.layoutShifts.length) {
916
+ return Lit.nothing;
687
917
  }
688
918
 
689
- #renderPageScopeSetting(): Lit.LitTemplate {
690
- if (!this.#cruxManager.getConfigSetting().get().enabled) {
691
- return Lit.nothing;
692
- }
919
+ // clang-format off
920
+ return html`
921
+ <ol class="log"
922
+ slot="layout-shifts-log-content"
923
+ ${Lit.Directives.ref(el => {
924
+ if (el instanceof HTMLElement) {
925
+ output.shouldKeepLayoutShiftsScrolledToBottom = () => {
926
+ return shouldKeepScrolledToBottom(el);
927
+ };
928
+ output.keepLayoutShiftsScrolledToBottom = () => {
929
+ keepScrolledToBottom(el);
930
+ };
931
+ }
932
+ })}
933
+ >
934
+ ${input.layoutShifts.map(layoutShift => {
935
+ const metricValue = renderMetricValue(
936
+ 'timeline.landing.layout-shift-event-score',
937
+ layoutShift.score,
938
+ CLS_THRESHOLDS,
939
+ // CLS value is 2 decimal places, but individual shift scores tend to be much smaller
940
+ // so we expand the precision here.
941
+ v => v.toFixed(4),
942
+ {dim: true},
943
+ );
944
+
945
+ return html`
946
+ <li id=${layoutShift.uniqueLayoutShiftId} class="log-item layout-shift" tabindex="-1">
947
+ <div class="layout-shift-score">Layout shift score: ${metricValue}</div>
948
+ <div class="layout-shift-nodes">
949
+ ${layoutShift.affectedNodeRefs.map(node => html`
950
+ <div class="layout-shift-node">
951
+ ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, {node})}
952
+ </div>
953
+ `)}
954
+ </div>
955
+ </li>
956
+ `;
957
+ })}
958
+ </ol>
959
+ `;
960
+ // clang-format on
961
+ }
693
962
 
694
- const urlLabel = this.#getPageScopeLabel('url');
695
- const originLabel = this.#getPageScopeLabel('origin');
696
-
697
- const buttonTitle = this.#cruxManager.fieldPageScope === 'url' ? urlLabel : originLabel;
698
- const accessibleTitle = i18nString(UIStrings.showFieldDataForPage, {PH1: buttonTitle});
699
-
700
- // If there is no data at all we should force users to switch pages or reconfigure CrUX.
701
- const shouldDisable = !this.#cruxManager.pageResult?.['url-ALL'] && !this.#cruxManager.pageResult?.['origin-ALL'];
702
-
703
- /* eslint-disable @devtools/no-deprecated-component-usages */
704
- return html`
705
- <devtools-select-menu
706
- id="page-scope-select"
707
- class="field-data-option"
708
- @selectmenuselected=${this.#onPageScopeMenuItemSelected}
709
- .showDivider=${true}
710
- .showArrow=${true}
711
- .sideButton=${false}
712
- .showSelectedItem=${true}
713
- .buttonTitle=${buttonTitle}
714
- .disabled=${shouldDisable}
715
- title=${accessibleTitle}
963
+ function renderLogSection(input: ViewInput, output: ViewOutput): Lit.LitTemplate {
964
+ // clang-format off
965
+ return html`
966
+ <section
967
+ class="logs-section"
968
+ aria-label=${i18nString(UIStrings.eventLogs)}
969
+ >
970
+ <devtools-widget ${widget(LiveMetricsLogs)}
971
+ ${widgetRef(LiveMetricsLogs, widget => {
972
+ if (input.highlightedInteractionId) {
973
+ widget.selectTab('interactions');
974
+ } else if (input.highlightedLayoutShiftClusterIds?.size) {
975
+ widget.selectTab('layout-shifts');
976
+ }
977
+ })}
716
978
  >
717
- <devtools-menu-item
718
- .value=${'url'}
719
- .selected=${this.#cruxManager.fieldPageScope === 'url'}
720
- >
721
- ${urlLabel}
722
- </devtools-menu-item>
723
- <devtools-menu-item
724
- .value=${'origin'}
725
- .selected=${this.#cruxManager.fieldPageScope === 'origin'}
726
- >
727
- ${originLabel}
728
- </devtools-menu-item>
729
- </devtools-select-menu>
730
- `;
731
- /* eslint-enable @devtools/no-deprecated-component-usages */
732
- }
979
+ ${renderInteractionsLog(input, output)}
980
+ ${renderLayoutShiftsLog(input, output)}
981
+ </devtools-widget>
982
+ </section>
983
+ `;
984
+ // clang-format on
985
+ }
733
986
 
734
- #getDeviceScopeDisplayName(deviceScope: CrUXManager.DeviceScope): string {
735
- switch (deviceScope) {
736
- case 'ALL':
737
- return i18nString(UIStrings.allDevices);
738
- case 'DESKTOP':
739
- return i18nString(UIStrings.desktop);
740
- case 'PHONE':
741
- return i18nString(UIStrings.mobile);
742
- case 'TABLET':
743
- return i18nString(UIStrings.tablet);
744
- }
987
+ function renderNodeView(input: ViewInput): Lit.LitTemplate {
988
+ return html`
989
+ <style>${liveMetricsViewStyles}</style>
990
+ <style>${metricValueStyles}</style>
991
+ <div class="node-view">
992
+ <main>
993
+ <h2 class="section-title">${i18nString(UIStrings.nodePerformanceTimeline)}</h2>
994
+ <div class="node-description">${i18nString(UIStrings.nodeClickToRecord)}</div>
995
+ <div class="record-action-card">${renderRecordAction(input.toggleRecordAction)}</div>
996
+ </main>
997
+ </div>
998
+ `;
999
+ }
1000
+
1001
+ export const DEFAULT_VIEW: View = (input, output, target) => {
1002
+ if (input.isNode) {
1003
+ Lit.render(renderNodeView(input), target, {host: input});
1004
+ return;
745
1005
  }
746
1006
 
747
- #getLabelForDeviceOption(deviceOption: DeviceOption): string {
748
- let baseLabel;
749
- if (deviceOption === 'AUTO') {
750
- const deviceScope = this.#cruxManager.resolveDeviceOptionToScope(deviceOption);
751
- const deviceScopeLabel = this.#getDeviceScopeDisplayName(deviceScope);
752
- baseLabel = i18nString(UIStrings.auto, {PH1: deviceScopeLabel});
753
- } else {
754
- baseLabel = this.#getDeviceScopeDisplayName(deviceOption);
1007
+ const fieldEnabled = input.cruxManager.getConfigSetting().get().enabled;
1008
+ const liveMetricsTitle =
1009
+ fieldEnabled ? i18nString(UIStrings.localAndFieldMetrics) : i18nString(UIStrings.localMetrics);
1010
+
1011
+ const helpLink = 'https://web.dev/articles/lab-and-field-data-differences#lab_data_versus_field_data' as
1012
+ Platform.DevToolsPath.UrlString;
1013
+
1014
+ // clang-format off
1015
+ const outputTemplate = html`
1016
+ <style>${liveMetricsViewStyles}</style>
1017
+ <style>${metricValueStyles}</style>
1018
+ <div class="container">
1019
+ <div class="live-metrics-view">
1020
+ <main class="live-metrics">
1021
+ <h2 class="section-title">${liveMetricsTitle}</h2>
1022
+ <div class="metric-cards">
1023
+ <div id="lcp">
1024
+ ${renderLcpCard(input)}
1025
+ </div>
1026
+ <div id="cls">
1027
+ ${renderClsCard(input)}
1028
+ </div>
1029
+ <div id="inp">
1030
+ ${renderInpCard(input)}
1031
+ </div>
1032
+ </div>
1033
+ <devtools-link
1034
+ href=${helpLink}
1035
+ class="local-field-link"
1036
+ title=${i18nString(UIStrings.localFieldLearnMoreTooltip)}
1037
+ >${i18nString(UIStrings.localFieldLearnMoreLink)}</devtools-link>
1038
+ ${renderLogSection(input, output)}
1039
+ </main>
1040
+ <aside class="next-steps" aria-labelledby="next-steps-section-title">
1041
+ <h2 id="next-steps-section-title" class="section-title">${i18nString(UIStrings.nextSteps)}</h2>
1042
+ <div id="field-setup" class="settings-card">
1043
+ <h3 class="card-title">${i18nString(UIStrings.fieldMetricsTitle)}</h3>
1044
+ ${renderFieldDataMessage(input.cruxManager)}
1045
+ ${renderPageScopeSetting(input)}
1046
+ ${renderDeviceScopeSetting(input)}
1047
+ <div class="field-setup-buttons">
1048
+ <devtools-field-settings-dialog></devtools-field-settings-dialog>
1049
+ </div>
1050
+ </div>
1051
+ <div id="recording-settings" class="settings-card">
1052
+ ${renderRecordingSettings(input)}
1053
+ </div>
1054
+ <div id="record" class="record-action-card">
1055
+ ${renderRecordAction(input.toggleRecordAction)}
1056
+ </div>
1057
+ <div id="record-page-load" class="record-action-card">
1058
+ ${renderRecordAction(input.recordReloadAction)}
1059
+ </div>
1060
+ </aside>
1061
+ </div>
1062
+ </div>
1063
+ `;
1064
+ Lit.render(outputTemplate, target, {host: input});
1065
+
1066
+ if (input.highlightedInteractionId) {
1067
+ const interactionEl = target.querySelector<HTMLElement>('#' + CSS.escape(input.highlightedInteractionId));
1068
+ if (interactionEl) {
1069
+ void RenderCoordinator.write(() => {
1070
+ interactionEl.scrollIntoView({
1071
+ block: 'center',
1072
+ });
1073
+ interactionEl.focus();
1074
+ UI.UIUtils.runCSSAnimationOnce(interactionEl, 'highlight');
1075
+ });
755
1076
  }
1077
+ }
756
1078
 
757
- if (!this.#cruxManager.pageResult) {
758
- return i18nString(UIStrings.loadingOption, {PH1: baseLabel});
1079
+ if (input.highlightedLayoutShiftClusterIds?.size) {
1080
+ const layoutShiftEls: HTMLElement[] = [];
1081
+ for (const shiftId of input.highlightedLayoutShiftClusterIds) {
1082
+ const layoutShiftEl = target.querySelector<HTMLElement>('#' + CSS.escape(shiftId));
1083
+ if (layoutShiftEl) {
1084
+ layoutShiftEls.push(layoutShiftEl);
1085
+ }
759
1086
  }
760
1087
 
761
- const result = this.#cruxManager.getSelectedFieldResponse();
762
- if (!result) {
763
- return i18nString(UIStrings.needsDataOption, {PH1: baseLabel});
1088
+ if (layoutShiftEls.length) {
1089
+ void RenderCoordinator.write(() => {
1090
+ layoutShiftEls[0].scrollIntoView({
1091
+ block: 'start',
1092
+ });
1093
+ layoutShiftEls[0].focus();
1094
+ for (const layoutShiftEl of layoutShiftEls) {
1095
+ UI.UIUtils.runCSSAnimationOnce(layoutShiftEl, 'highlight');
1096
+ }
1097
+ });
764
1098
  }
765
-
766
- return baseLabel;
767
1099
  }
1100
+ };
768
1101
 
769
- #onDeviceOptionMenuItemSelected(event: Menus.SelectMenu.SelectMenuItemSelectedEvent): void {
770
- this.#cruxManager.fieldDeviceOption = event.itemValue as DeviceOption;
771
- void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
772
- }
1102
+ export class LiveMetricsView extends UI.Widget.Widget {
1103
+ isNode = Root.Runtime.Runtime.isNode();
773
1104
 
774
- #renderDeviceScopeSetting(): Lit.LitTemplate {
775
- if (!this.#cruxManager.getConfigSetting().get().enabled) {
776
- return Lit.nothing;
777
- }
1105
+ #lcpValue?: LiveMetrics.LcpValue;
1106
+ #clsValue?: LiveMetrics.ClsValue;
1107
+ #inpValue?: LiveMetrics.InpValue;
1108
+ #interactions: LiveMetrics.InteractionMap = new Map();
1109
+ #layoutShifts: LiveMetrics.LayoutShift[] = [];
778
1110
 
779
- // If there is no data at all we should force users to try adjusting the page scope
780
- // before coming back to this option.
781
- const shouldDisable = !this.#cruxManager.getFieldResponse(this.#cruxManager.fieldPageScope, 'ALL');
782
-
783
- const currentDeviceLabel = this.#getLabelForDeviceOption(this.#cruxManager.fieldDeviceOption);
784
-
785
- // clang-format off
786
- /* eslint-disable @devtools/no-deprecated-component-usages */
787
- return html`
788
- <devtools-select-menu
789
- id="device-scope-select"
790
- class="field-data-option"
791
- @selectmenuselected=${this.#onDeviceOptionMenuItemSelected}
792
- .showDivider=${true}
793
- .showArrow=${true}
794
- .sideButton=${false}
795
- .showSelectedItem=${true}
796
- .buttonTitle=${i18nString(UIStrings.device, {PH1: currentDeviceLabel})}
797
- .disabled=${shouldDisable}
798
- title=${i18nString(UIStrings.showFieldDataForDevice, {PH1: currentDeviceLabel})}
799
- >
800
- ${DEVICE_OPTION_LIST.map(deviceOption => {
801
- return html`
802
- <devtools-menu-item
803
- .value=${deviceOption}
804
- .selected=${this.#cruxManager.fieldDeviceOption === deviceOption}
805
- >
806
- ${this.#getLabelForDeviceOption(deviceOption)}
807
- </devtools-menu-item>
808
- `;
809
- })}
810
- </devtools-select-menu>
811
- `;
812
- /* eslint-enable @devtools/no-deprecated-component-usages */
813
- // clang-format on
814
- }
1111
+ #highlightedInteractionId = '';
1112
+ #highlightedLayoutShiftClusterIds = new Set<string>();
815
1113
 
816
- #getCollectionPeriodRange(): string|null {
817
- const selectedResponse = this.#cruxManager.getSelectedFieldResponse();
818
- if (!selectedResponse) {
819
- return null;
820
- }
1114
+ #cruxManager = CrUXManager.CrUXManager.instance();
821
1115
 
822
- const {firstDate, lastDate} = selectedResponse.record.collectionPeriod;
823
-
824
- const formattedFirstDate = new Date(
825
- firstDate.year,
826
- // CrUX month is 1-indexed but `Date` month is 0-indexed
827
- firstDate.month - 1,
828
- firstDate.day,
829
- );
830
- const formattedLastDate = new Date(
831
- lastDate.year,
832
- // CrUX month is 1-indexed but `Date` month is 0-indexed
833
- lastDate.month - 1,
834
- lastDate.day,
835
- );
836
-
837
- const options: Intl.DateTimeFormatOptions = {
838
- year: 'numeric',
839
- month: 'short',
840
- day: 'numeric',
841
- };
1116
+ #toggleRecordAction: UI.ActionRegistration.Action;
1117
+ #recordReloadAction: UI.ActionRegistration.Action;
842
1118
 
843
- return i18nString(UIStrings.dateRange, {
844
- PH1: formattedFirstDate.toLocaleDateString(undefined, options),
845
- PH2: formattedLastDate.toLocaleDateString(undefined, options),
846
- });
847
- }
1119
+ #view: View;
1120
+ #viewOutput: ViewOutput = {};
1121
+ #deviceModeModel = EmulationModel.DeviceModeModel.DeviceModeModel.tryInstance();
848
1122
 
849
- #renderFieldDataHistoryLink(): Lit.LitTemplate {
850
- if (!this.#cruxManager.getConfigSetting().get().enabled) {
851
- return Lit.nothing;
852
- }
853
- const normalizedUrl = this.#cruxManager.pageResult?.normalizedUrl;
854
- if (!normalizedUrl) {
855
- return Lit.nothing;
856
- }
857
- const tmp = new URL('https://cruxvis.withgoogle.com/');
858
- tmp.searchParams.set('view', 'cwvsummary');
859
- tmp.searchParams.set('url', normalizedUrl);
860
- // identifier must be 'origin' or 'url'.
861
- const identifier = this.#cruxManager.fieldPageScope;
862
- tmp.searchParams.set('identifier', identifier);
863
- // device must be one 'PHONE', 'DESKTOP', 'TABLET', or 'ALL'.
864
- const device = this.#cruxManager.getSelectedDeviceScope();
865
- tmp.searchParams.set('device', device);
866
- const cruxVis = `${tmp.origin}/#/${tmp.search}`;
867
- return html`
868
- (<devtools-link href=${cruxVis}
869
- class="local-field-link"
870
- title=${i18nString(UIStrings.fieldDataHistoryTooltip)}
871
- >${i18nString(UIStrings.fieldDataHistoryLink)}</devtools-link>)
872
- `;
873
- }
1123
+ constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
1124
+ super(element, {useShadowDom: true});
1125
+ this.#view = view;
874
1126
 
875
- #renderCollectionPeriod(): Lit.LitTemplate {
876
- const range = this.#getCollectionPeriodRange();
1127
+ this.#toggleRecordAction = UI.ActionRegistry.ActionRegistry.instance().getAction('timeline.toggle-recording');
1128
+ this.#recordReloadAction = UI.ActionRegistry.ActionRegistry.instance().getAction('timeline.record-reload');
1129
+ }
877
1130
 
878
- const dateEl = document.createElement('span');
879
- dateEl.classList.add('collection-period-range');
880
- dateEl.textContent = range || i18nString(UIStrings.notEnoughData);
1131
+ async #onMetricStatus(event: {data: LiveMetrics.StatusEvent}): Promise<void> {
1132
+ this.#lcpValue = event.data.lcp;
1133
+ this.#clsValue = event.data.cls;
1134
+ this.#inpValue = event.data.inp;
881
1135
 
882
- const message = uiI18n.getFormatLocalizedString(str_, UIStrings.collectionPeriod, {
883
- PH1: dateEl,
884
- });
1136
+ const hasNewLS = this.#layoutShifts.length < event.data.layoutShifts.length;
1137
+ this.#layoutShifts = [...event.data.layoutShifts];
885
1138
 
886
- const fieldDataHistoryLink = range ? this.#renderFieldDataHistoryLink() : Lit.nothing;
1139
+ const hasNewInteraction = this.#interactions.size < event.data.interactions.size;
1140
+ this.#interactions = new Map(event.data.interactions);
887
1141
 
888
- const warnings = this.#cruxManager.pageResult?.warnings || [];
1142
+ const shouldScrollInteractions = hasNewInteraction && this.#viewOutput.shouldKeepInteractionsScrolledToBottom?.();
1143
+ const shouldScrollLS = hasNewLS && this.#viewOutput.shouldKeepLayoutShiftsScrolledToBottom?.();
889
1144
 
890
- return html`
891
- <div class="field-data-message">
892
- <div>${message} ${fieldDataHistoryLink}</div>
893
- ${warnings.map(warning => html`
894
- <div class="field-data-warning">${warning}</div>
895
- `)}
896
- </div>
897
- `;
898
- }
1145
+ this.requestUpdate();
1146
+ await this.updateComplete;
899
1147
 
900
- #renderFieldDataMessage(): Lit.LitTemplate {
901
- if (this.#cruxManager.getConfigSetting().get().enabled) {
902
- return this.#renderCollectionPeriod();
1148
+ if (shouldScrollInteractions) {
1149
+ this.#viewOutput.keepInteractionsScrolledToBottom?.();
903
1150
  }
904
1151
 
905
- // clang-format off
906
- return html`
907
- <div class="field-data-message">
908
- ${uiI18n.getFormatLocalizedStringTemplate(
909
- str_,
910
- UIStrings.seeHowYourLocalMetricsCompare,
911
- { PH1: html`<devtools-link href="https://developer.chrome.com/docs/crux">${i18n.i18n.lockedString('Chrome UX Report')}</devtools-link>` },
912
- )}
913
- </div>
914
- `;
915
- // clang-format on
1152
+ if (shouldScrollLS) {
1153
+ this.#viewOutput.keepLayoutShiftsScrolledToBottom?.();
1154
+ }
916
1155
  }
917
1156
 
918
- #renderLogSection(): Lit.LitTemplate {
919
- // clang-format off
920
- return html`
921
- <section
922
- class="logs-section"
923
- aria-label=${i18nString(UIStrings.eventLogs)}
924
- >
925
- <devtools-live-metrics-logs
926
- ${Lit.Directives.ref(el => {
927
- if (el instanceof HTMLElement) {
928
- this.#logsEl = el as LiveMetricsLogs;
929
- }
930
- })}
931
- >
932
- ${this.#renderInteractionsLog()}
933
- ${this.#renderLayoutShiftsLog()}
934
- </devtools-live-metrics-logs>
935
- </section>
936
- `;
937
- // clang-format on
1157
+ #onFieldDataChanged(): void {
1158
+ this.requestUpdate();
938
1159
  }
939
1160
 
940
- async #revealInteraction(interaction: LiveMetrics.Interaction): Promise<void> {
941
- const interactionEl = this.#shadow.getElementById(interaction.interactionId);
942
- if (!interactionEl || !this.#logsEl) {
943
- return;
944
- }
945
-
946
- const success = this.#logsEl.selectTab('interactions');
947
- if (!success) {
948
- return;
949
- }
950
-
951
- await RenderCoordinator.write(() => {
952
- interactionEl.scrollIntoView({
953
- block: 'center',
954
- });
955
- interactionEl.focus();
956
- UI.UIUtils.runCSSAnimationOnce(interactionEl, 'highlight');
957
- });
1161
+ #onEmulationChanged(): void {
1162
+ this.requestUpdate();
958
1163
  }
959
1164
 
960
- async #logExtraInteractionDetails(interaction: LiveMetrics.Interaction): Promise<void> {
961
- const success = await LiveMetrics.LiveMetrics.instance().logInteractionScripts(interaction);
962
- if (success) {
963
- await Common.Console.Console.instance().showPromise();
1165
+ async #refreshFieldDataForCurrentPage(): Promise<void> {
1166
+ if (!this.isNode) {
1167
+ await this.#cruxManager.refresh();
964
1168
  }
1169
+ this.requestUpdate();
965
1170
  }
966
1171
 
967
- #renderInteractionsLog(): Lit.LitTemplate {
968
- if (!this.#interactions.size) {
969
- return Lit.nothing;
970
- }
1172
+ override wasShown(): void {
1173
+ super.wasShown();
1174
+ const liveMetrics = LiveMetrics.LiveMetrics.instance();
1175
+ liveMetrics.addEventListener(LiveMetrics.Events.STATUS, this.#onMetricStatus, this);
971
1176
 
972
- // clang-format off
973
- return html`
974
- <ol class="log"
975
- slot="interactions-log-content"
976
- ${Lit.Directives.ref(el => {
977
- if (el instanceof HTMLElement) {
978
- this.#interactionsListEl = el as HTMLElement;
979
- }
980
- })}
981
- >
982
- ${this.#interactions.values().map(interaction => {
983
- const metricValue = renderMetricValue(
984
- 'timeline.landing.interaction-event-timing',
985
- interaction.duration,
986
- INP_THRESHOLDS,
987
- v => i18n.TimeUtilities.preciseMillisToString(v),
988
- {dim: true},
989
- );
990
-
991
- const isP98Excluded = this.#inpValue && this.#inpValue.value < interaction.duration;
992
- const isInp = this.#inpValue?.interactionId === interaction.interactionId;
993
-
994
- return html`
995
- <li id=${interaction.interactionId} class="log-item interaction" tabindex="-1">
996
- <details>
997
- <summary>
998
- <span class="interaction-type">
999
- ${interaction.interactionType} ${isInp ?
1000
- html`<span class="interaction-inp-chip" title=${i18nString(UIStrings.inpInteraction)}>INP</span>`
1001
- : nothing}
1002
- </span>
1003
- <span class="interaction-node">
1004
- ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, {node: interaction.nodeRef})}
1005
- </span>
1006
- ${isP98Excluded ? html`<devtools-icon
1007
- class="interaction-info"
1008
- name="info"
1009
- title=${i18nString(UIStrings.interactionExcluded)}
1010
- ></devtools-icon>` : nothing}
1011
- <span class="interaction-duration">${metricValue}</span>
1012
- </summary>
1013
- <div class="phase-table" role="table">
1014
- <div class="phase-table-row phase-table-header-row" role="row">
1015
- <div role="columnheader">${i18nString(UIStrings.phase)}</div>
1016
- <div role="columnheader">
1017
- ${interaction.longAnimationFrameTimings.length ? html`
1018
- <button
1019
- class="log-extra-details-button"
1020
- title=${i18nString(UIStrings.logToConsole)}
1021
- @click=${() => this.#logExtraInteractionDetails(interaction)}
1022
- >${i18nString(UIStrings.duration)}</button>
1023
- ` : i18nString(UIStrings.duration)}
1024
- </div>
1025
- </div>
1026
- <div class="phase-table-row" role="row">
1027
- <div role="cell">${i18nString(UIStrings.inputDelay)}</div>
1028
- <div role="cell">${Math.round(interaction.phases.inputDelay)}</div>
1029
- </div>
1030
- <div class="phase-table-row" role="row">
1031
- <div role="cell">${i18nString(UIStrings.processingDuration)}</div>
1032
- <div role="cell">${Math.round(interaction.phases.processingDuration)}</div>
1033
- </div>
1034
- <div class="phase-table-row" role="row">
1035
- <div role="cell">${i18nString(UIStrings.presentationDelay)}</div>
1036
- <div role="cell">${Math.round(interaction.phases.presentationDelay)}</div>
1037
- </div>
1038
- </div>
1039
- </details>
1040
- </li>
1041
- `;
1042
- })}
1043
- </ol>
1044
- `;
1045
- // clang-format on
1046
- }
1177
+ const cruxManager = CrUXManager.CrUXManager.instance();
1178
+ cruxManager.addEventListener(CrUXManager.Events.FIELD_DATA_CHANGED, this.#onFieldDataChanged, this);
1047
1179
 
1048
- async #revealLayoutShiftCluster(clusterIds: Set<LiveMetrics.LayoutShift['uniqueLayoutShiftId']>): Promise<void> {
1049
- if (!this.#logsEl) {
1050
- return;
1051
- }
1180
+ this.#deviceModeModel?.addEventListener(
1181
+ EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
1052
1182
 
1053
- const layoutShiftEls: HTMLElement[] = [];
1054
- for (const shiftId of clusterIds) {
1055
- const layoutShiftEl = this.#shadow.getElementById(shiftId);
1056
- if (layoutShiftEl) {
1057
- layoutShiftEls.push(layoutShiftEl);
1058
- }
1183
+ if (cruxManager.getConfigSetting().get().enabled) {
1184
+ void this.#refreshFieldDataForCurrentPage();
1059
1185
  }
1060
1186
 
1061
- if (!layoutShiftEls.length) {
1062
- return;
1063
- }
1187
+ this.#lcpValue = liveMetrics.lcpValue;
1188
+ this.#clsValue = liveMetrics.clsValue;
1189
+ this.#inpValue = liveMetrics.inpValue;
1190
+ this.#interactions = liveMetrics.interactions;
1191
+ this.#layoutShifts = liveMetrics.layoutShifts;
1192
+ this.requestUpdate();
1193
+ }
1064
1194
 
1065
- const success = this.#logsEl.selectTab('layout-shifts');
1066
- if (!success) {
1067
- return;
1068
- }
1195
+ override willHide(): void {
1196
+ super.willHide();
1197
+ LiveMetrics.LiveMetrics.instance().removeEventListener(LiveMetrics.Events.STATUS, this.#onMetricStatus, this);
1069
1198
 
1070
- await RenderCoordinator.write(() => {
1071
- layoutShiftEls[0].scrollIntoView({
1072
- block: 'start',
1073
- });
1074
- layoutShiftEls[0].focus();
1075
- for (const layoutShiftEl of layoutShiftEls) {
1076
- UI.UIUtils.runCSSAnimationOnce(layoutShiftEl, 'highlight');
1077
- }
1078
- });
1199
+ const cruxManager = CrUXManager.CrUXManager.instance();
1200
+ cruxManager.removeEventListener(CrUXManager.Events.FIELD_DATA_CHANGED, this.#onFieldDataChanged, this);
1201
+
1202
+ this.#deviceModeModel?.removeEventListener(
1203
+ EmulationModel.DeviceModeModel.Events.UPDATED, this.#onEmulationChanged, this);
1079
1204
  }
1080
1205
 
1081
- #renderLayoutShiftsLog(): Lit.LitTemplate {
1082
- if (!this.#layoutShifts.length) {
1083
- return Lit.nothing;
1206
+ #onPageScopeMenuItemSelected(event: Menus.SelectMenu.SelectMenuItemSelectedEvent): void {
1207
+ if (event.itemValue === 'url') {
1208
+ this.#cruxManager.fieldPageScope = 'url';
1209
+ } else {
1210
+ this.#cruxManager.fieldPageScope = 'origin';
1084
1211
  }
1212
+ this.requestUpdate();
1213
+ }
1085
1214
 
1086
- // clang-format off
1087
- return html`
1088
- <ol class="log"
1089
- slot="layout-shifts-log-content"
1090
- ${Lit.Directives.ref(el => {
1091
- if (el instanceof HTMLElement) {
1092
- this.#layoutShiftsListEl = el as HTMLElement;
1093
- }
1094
- })}
1095
- >
1096
- ${this.#layoutShifts.map(layoutShift => {
1097
- const metricValue = renderMetricValue(
1098
- 'timeline.landing.layout-shift-event-score',
1099
- layoutShift.score,
1100
- CLS_THRESHOLDS,
1101
- // CLS value is 2 decimal places, but individual shift scores tend to be much smaller
1102
- // so we expand the precision here.
1103
- v => v.toFixed(4),
1104
- {dim: true},
1105
- );
1106
-
1107
- return html`
1108
- <li id=${layoutShift.uniqueLayoutShiftId} class="log-item layout-shift" tabindex="-1">
1109
- <div class="layout-shift-score">Layout shift score: ${metricValue}</div>
1110
- <div class="layout-shift-nodes">
1111
- ${layoutShift.affectedNodeRefs.map(node => html`
1112
- <div class="layout-shift-node">
1113
- ${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, {node})}
1114
- </div>
1115
- `)}
1116
- </div>
1117
- </li>
1118
- `;
1119
- })}
1120
- </ol>
1121
- `;
1122
- // clang-format on
1215
+ #onDeviceOptionMenuItemSelected(event: Menus.SelectMenu.SelectMenuItemSelectedEvent): void {
1216
+ this.#cruxManager.fieldDeviceOption = event.itemValue as DeviceOption;
1217
+ this.requestUpdate();
1123
1218
  }
1124
1219
 
1125
- #renderNodeView(): Lit.LitTemplate {
1126
- return html`
1127
- <style>${liveMetricsViewStyles}</style>
1128
- <style>${metricValueStyles}</style>
1129
- <div class="node-view">
1130
- <main>
1131
- <h2 class="section-title">${i18nString(UIStrings.nodePerformanceTimeline)}</h2>
1132
- <div class="node-description">${i18nString(UIStrings.nodeClickToRecord)}</div>
1133
- <div class="record-action-card">${this.#renderRecordAction(this.#toggleRecordAction)}</div>
1134
- </main>
1135
- </div>
1136
- `;
1220
+ async #revealInteraction(interaction: LiveMetrics.Interaction): Promise<void> {
1221
+ this.#highlightedInteractionId = interaction.interactionId;
1222
+ this.requestUpdate();
1223
+ await this.updateComplete;
1224
+ this.#highlightedInteractionId = '';
1137
1225
  }
1138
1226
 
1139
- #render = (): void => {
1140
- if (this.isNode) {
1141
- Lit.render(this.#renderNodeView(), this.#shadow, {host: this});
1142
- return;
1227
+ async #logExtraInteractionDetails(interaction: LiveMetrics.Interaction): Promise<void> {
1228
+ const success = await LiveMetrics.LiveMetrics.instance().logInteractionScripts(interaction);
1229
+ if (success) {
1230
+ await Common.Console.Console.instance().showPromise();
1143
1231
  }
1232
+ }
1144
1233
 
1145
- const fieldEnabled = this.#cruxManager.getConfigSetting().get().enabled;
1146
- const liveMetricsTitle =
1147
- fieldEnabled ? i18nString(UIStrings.localAndFieldMetrics) : i18nString(UIStrings.localMetrics);
1148
-
1149
- const helpLink = 'https://web.dev/articles/lab-and-field-data-differences#lab_data_versus_field_data' as
1150
- Platform.DevToolsPath.UrlString;
1151
-
1152
- // clang-format off
1153
- const output = html`
1154
- <style>${liveMetricsViewStyles}</style>
1155
- <style>${metricValueStyles}</style>
1156
- <div class="container">
1157
- <div class="live-metrics-view">
1158
- <main class="live-metrics">
1159
- <h2 class="section-title">${liveMetricsTitle}</h2>
1160
- <div class="metric-cards"
1161
- ${Lit.Directives.ref(el => {
1162
- if (el instanceof HTMLElement) {
1163
- this.#tooltipContainerEl = el;
1164
- }
1165
- })}
1166
- >
1167
- <div id="lcp">
1168
- ${this.#renderLcpCard()}
1169
- </div>
1170
- <div id="cls">
1171
- ${this.#renderClsCard()}
1172
- </div>
1173
- <div id="inp">
1174
- ${this.#renderInpCard()}
1175
- </div>
1176
- </div>
1177
- <devtools-link
1178
- href=${helpLink}
1179
- class="local-field-link"
1180
- title=${i18nString(UIStrings.localFieldLearnMoreTooltip)}
1181
- >${i18nString(UIStrings.localFieldLearnMoreLink)}</devtools-link>
1182
- ${this.#renderLogSection()}
1183
- </main>
1184
- <aside class="next-steps" aria-labelledby="next-steps-section-title">
1185
- <h2 id="next-steps-section-title" class="section-title">${i18nString(UIStrings.nextSteps)}</h2>
1186
- <div id="field-setup" class="settings-card">
1187
- <h3 class="card-title">${i18nString(UIStrings.fieldMetricsTitle)}</h3>
1188
- ${this.#renderFieldDataMessage()}
1189
- ${this.#renderPageScopeSetting()}
1190
- ${this.#renderDeviceScopeSetting()}
1191
- <div class="field-setup-buttons">
1192
- <devtools-field-settings-dialog></devtools-field-settings-dialog>
1193
- </div>
1194
- </div>
1195
- <div id="recording-settings" class="settings-card">
1196
- ${this.#renderRecordingSettings()}
1197
- </div>
1198
- <div id="record" class="record-action-card">
1199
- ${this.#renderRecordAction(this.#toggleRecordAction)}
1200
- </div>
1201
- <div id="record-page-load" class="record-action-card">
1202
- ${this.#renderRecordAction(this.#recordReloadAction)}
1203
- </div>
1204
- </aside>
1205
- </div>
1206
- </div>
1207
- `;
1208
- Lit.render(output, this.#shadow, {host: this});
1209
- };
1210
- // clang-format on
1211
- }
1234
+ async #revealLayoutShiftCluster(clusterIds: Set<LiveMetrics.LayoutShift['uniqueLayoutShiftId']>): Promise<void> {
1235
+ this.#highlightedLayoutShiftClusterIds = clusterIds;
1236
+ this.requestUpdate();
1237
+ await this.updateComplete;
1238
+ this.#highlightedLayoutShiftClusterIds = new Set();
1239
+ }
1212
1240
 
1213
- class LiveMetricsLogs extends UI.Widget.WidgetElement<UI.Widget.Widget> {
1214
- #tabbedPane?: UI.TabbedPane.TabbedPane;
1241
+ override performUpdate(): void {
1242
+ const viewInput: ViewInput = {
1243
+ isNode: this.isNode,
1244
+ lcpValue: this.#lcpValue,
1245
+ clsValue: this.#clsValue,
1246
+ inpValue: this.#inpValue,
1247
+ interactions: this.#interactions,
1248
+ layoutShifts: this.#layoutShifts,
1249
+ toggleRecordAction: this.#toggleRecordAction,
1250
+ recordReloadAction: this.#recordReloadAction,
1251
+ cruxManager: this.#cruxManager,
1252
+ handlePageScopeSelected: this.#onPageScopeMenuItemSelected.bind(this),
1253
+ handleDeviceOptionSelected: this.#onDeviceOptionMenuItemSelected.bind(this),
1254
+ revealLayoutShiftCluster: this.#revealLayoutShiftCluster.bind(this),
1255
+ revealInteraction: this.#revealInteraction.bind(this),
1256
+ logExtraInteractionDetails: this.#logExtraInteractionDetails.bind(this),
1257
+ highlightedInteractionId: this.#highlightedInteractionId,
1258
+ highlightedLayoutShiftClusterIds: this.#highlightedLayoutShiftClusterIds,
1259
+ };
1215
1260
 
1216
- constructor() {
1217
- super();
1218
- this.style.display = 'contents';
1261
+ this.#view(viewInput, this.#viewOutput, this.contentElement);
1219
1262
  }
1263
+ }
1264
+
1265
+ class LiveMetricsLogs extends UI.Widget.Widget {
1266
+ #tabbedPane: UI.TabbedPane.TabbedPane;
1220
1267
 
1221
1268
  /**
1222
1269
  * Returns `true` if selecting the tab was successful.
1223
1270
  */
1224
1271
  selectTab(tabId: string): boolean {
1225
- if (!this.#tabbedPane) {
1226
- return false;
1227
- }
1228
1272
  return this.#tabbedPane.selectTab(tabId);
1229
1273
  }
1230
1274
 
1231
1275
  #clearCurrentLog(): void {
1232
1276
  const liveMetrics = LiveMetrics.LiveMetrics.instance();
1233
1277
 
1234
- switch (this.#tabbedPane?.selectedTabId) {
1278
+ switch (this.#tabbedPane.selectedTabId) {
1235
1279
  case 'interactions':
1236
1280
  liveMetrics.clearInteractions();
1237
1281
  break;
@@ -1240,16 +1284,12 @@ class LiveMetricsLogs extends UI.Widget.WidgetElement<UI.Widget.Widget> {
1240
1284
  break;
1241
1285
  }
1242
1286
  }
1243
-
1244
- override createWidget(): UI.Widget.Widget {
1245
- // We need a generic widget with a shadow DOM as the container widget so that we can take advantage
1246
- // of web component slots. Passing `this` into the container widget will make `this` the root element
1247
- // of that widget.
1248
- //
1287
+ constructor(element: HTMLElement) {
1288
+ super(element, {useShadowDom: true});
1289
+ this.element.style.display = 'contents';
1249
1290
  // Any children of the root element `this` will be matched to the slots defined within the container
1250
1291
  // widget's shadow DOM.
1251
- const containerWidget = new UI.Widget.Widget(this, {useShadowDom: true});
1252
- containerWidget.contentElement.style.display = 'contents';
1292
+ this.contentElement.style.display = 'contents';
1253
1293
 
1254
1294
  this.#tabbedPane = new UI.TabbedPane.TabbedPane();
1255
1295
 
@@ -1273,18 +1313,6 @@ class LiveMetricsLogs extends UI.Widget.WidgetElement<UI.Widget.Widget> {
1273
1313
  i18nString(UIStrings.clearCurrentLog), 'clear', undefined, 'timeline.landing.clear-log');
1274
1314
  clearButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, this.#clearCurrentLog, this);
1275
1315
  this.#tabbedPane.rightToolbar().appendToolbarItem(clearButton);
1276
- this.#tabbedPane.show(containerWidget.contentElement);
1277
-
1278
- return containerWidget;
1279
- }
1280
- }
1281
-
1282
- customElements.define('devtools-live-metrics-view', LiveMetricsView);
1283
- customElements.define('devtools-live-metrics-logs', LiveMetricsLogs);
1284
-
1285
- declare global {
1286
- interface HTMLElementTagNameMap {
1287
- 'devtools-live-metrics-view': LiveMetricsView;
1288
- 'devtools-live-metrics-logs': LiveMetricsLogs;
1316
+ this.#tabbedPane.show(this.contentElement);
1289
1317
  }
1290
1318
  }