├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── check-web-vitals-js.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── icons ├── default128w.png ├── default16w.png ├── default256w.png ├── default48w.png ├── default512w.png ├── fast128w.png ├── fast16w.png ├── fast256w.png ├── fast48w.png ├── fast512w.png ├── icon128.png ├── icon16.png ├── icon19.png ├── icon48.png ├── slow128w-3.png ├── slow128w-cls.png ├── slow128w-fid.png ├── slow128w-inp.png ├── slow128w-lcp.png ├── slow128w.png ├── slow16w.png ├── slow256w.png ├── slow48w.png ├── slow512w.png ├── vitals128w.png ├── vitals256w.png ├── vitals48w.png └── vitals512w.png ├── manifest.json ├── media ├── cwv-extension-badge.png ├── cwv-extension-console.png ├── cwv-extension-drilldown-2.png ├── cwv-extension-drilldown.png ├── cwv-extension-overlay.png ├── cwv-extension-screenshot.png ├── devtools-fcp.png ├── devtools-field-data.png ├── devtools-interaction-log.png ├── devtools-interaction-phases.png ├── devtools-layout-shift-log.png ├── devtools-lcp-attribution.png ├── devtools-lcp-phases.png ├── devtools-live-metrics.png ├── devtools-loaf-attribution.png ├── devtools-panel.png └── devtools-ttfb.png ├── package-lock.json ├── package.json ├── service_worker.js ├── src ├── browser_action │ ├── browser_action.html │ ├── chrome.js │ ├── core.css │ ├── crux.js │ ├── lodash-debounce-custom.js │ ├── metric.js │ ├── on-each-interaction.js │ ├── popup.html │ ├── popup.js │ ├── viewer.css │ ├── vitals.js │ └── web-vitals.js ├── inject │ └── inject.js └── options │ ├── options.html │ └── options.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | trim_trailing_whitespace = true 11 | 12 | [*.js] 13 | insert_final_newline = true 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/browser_action/web-vitals.js 2 | src/browser_action/popup.js 3 | src/browser_action/lodash-debounce-custom.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'es6': true, 5 | }, 6 | 'extends': [ 7 | 'google', 8 | ], 9 | 'globals': { 10 | 'Atomics': 'readonly', 11 | 'SharedArrayBuffer': 'readonly', 12 | }, 13 | 'parser': 'babel-eslint', 14 | 'parserOptions': { 15 | 'ecmaVersion': 2018, 16 | 'sourceType': 'module', 17 | 'allowImportExportEverywhere': true 18 | }, 19 | 'rules': { 20 | 'max-len': ["error", { "code": 150 }] 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something is not working as expected 4 | labels: 5 | --- 6 | 7 | **Before you start** 8 | Please take a look at the [FAQ](https://github.com/GoogleChrome/web-vitals-extension#faq) as well as the already opened issues! If nothing fits your problem, go ahead and fill out the following template: 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Version:** 20 | 21 | - OS w/ version: [e.g. iOS 13] 22 | - Browser w/ version [e.g. Chrome 81] 23 | 24 | **Additional context, screenshots, screencasts** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the feature request here. 15 | -------------------------------------------------------------------------------- /.github/workflows/check-web-vitals-js.yml: -------------------------------------------------------------------------------- 1 | name: Test Web Vitals script matches node 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | paths: 6 | - 'package.json' 7 | - 'package-lock.json' 8 | push: 9 | branches: 10 | - main 11 | jobs: 12 | install: 13 | name: Install and test 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout branch 17 | uses: actions/checkout@v3 18 | - name: Setup Node.js for use with actions 19 | uses: actions/setup-node@v3 20 | - name: Install dependencies 21 | run: npm install 22 | - name: Check web-vitals matches 23 | run: diff node_modules/web-vitals/dist/web-vitals.attribution.js src/browser_action/web-vitals.js 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # ignore node_modules contents 3 | **/node_modules/* 4 | # don't ignore web-vitals 5 | !**/node_modules/web-vitals/ 6 | # do ignore web-vitals contents 7 | **/node_modules/web-vitals/* 8 | # don't ignore web-vitals/dist 9 | !**/node_modules/web-vitals/dist/ 10 | # do ignore web-vitals/dist contents 11 | **/node_modules/web-vitals/dist/* 12 | # don't ignore web-vitals.min.js 13 | !**/node_modules/web-vitals/dist/web-vitals.es5.min.js 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google.com/conduct/). -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020 Google LLC 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > The Chrome team has been working hard to bring the best of the Web Vitals extension directly into the DevTools Performance panel. As of Chrome version 132, which became stable on January 7, 2025, we have finally ended support for the extension and encourage all users to [switch to DevTools](#devtools). Be aware that extension updates will stop and features may break without notice. [Learn more](https://developer.chrome.com/blog/web-vitals-extension) 3 | 4 | # Web Vitals Chrome Extension 5 | *A Chrome extension to measure metrics for a healthy site* 6 | 7 | 8 | 9 | This extension measures the three [Core Web Vitals](https://web.dev/articles/vitals) metrics in a way that matches how they're measured by Chrome and reported to other Google tools (e.g. [Chrome User Experience Report](https://developer.chrome.com/docs/crux), [Page Speed Insights](https://developers.google.com/speed/pagespeed/insights/), [Search Console](https://search.google.com/search-console/about)). 10 | 11 | It supports all of the [Core Web Vitals](https://web.dev/articles/vitals/#core-web-vitals) and leverages the [web-vitals](https://github.com/GoogleChrome/web-vitals) library under the hood to capture: 12 | 13 | * [Largest Contentful Paint](https://web.dev/articles/lcp) 14 | * [Cumulative Layout Shift](https://web.dev/articles/cls) 15 | * [Interaction to Next Paint](https://web.dev/articles/inp) 16 | 17 | It also supports the diagnostic metrics: 18 | * [Time to First Byte](https://web.dev/articles/ttfb) 19 | * [First Contentful Paint](https://web.dev/articles/fcp) 20 | 21 |

✅ Migrating to DevTools

22 | 23 | Now that the Web Vitals extension has reached "end of life" stage, the Chrome team has stopped maintaining it. All users are strongly encouraged to start using the comparable functionality built into the DevTools Performance panel instead. 24 | 25 | The Performance panel is a much more powerful debugging tool than the extension alone. That said, there may be some functional or ergonomic features of the extensions still missing from DevTools. If there's a critical feature of the extension blocking your migration to DevTools, please +1 the relevant feature request in the [hotlist](https://goo.gle/devtools-live-cwv-hotlist) or [file a new issue](https://issues.chromium.org/issues/new?component=1457310&template=1946799&type=feature_request&priority=P2&pli=1&title=Live%20Metrics) to help get it prioritized. 26 | 27 | ![DevTools live metrics view](media/devtools-panel.png) 28 | 29 | Refer to the [official DevTools Performance panel documentation](https://developer.chrome.com/docs/devtools/performance/overview) for information about using each feature. As of Chrome 132 in January 2025, here is a quick summary of the extension features supported by DevTools: 30 | 31 | #### Live metrics 32 | 33 | When you open the Performance panel, the view defaults to showing a live look at your local Core Web Vitals metrics. 34 | 35 | ![LCP, CLS, and INP live metrics in DevTools](media/devtools-live-metrics.png) 36 | 37 | #### Field data 38 | 39 | Use the "Field data" configuration flow to show field data from CrUX next to your local metrics. URL and origin-level data is available for both desktop and mobile. 40 | 41 | ![Field data in DevTools](media/devtools-field-data.png) 42 | 43 | #### LCP element attribution 44 | 45 | You can find a reference to the element responsible for the LCP metric at the bottom of the LCP section. 46 | 47 | ![LCP attribution in DevTools](media/devtools-lcp-attribution.png) 48 | 49 | #### LCP phases 50 | 51 | Hover over the LCP metric values to display a card with more information about your local and field LCP performance, including a breakdown of your local LCP phases: TTFB, load delay, load duration, and render delay. 52 | 53 | ![LCP phases in DevTools](media/devtools-lcp-phases.png) 54 | 55 | #### Interaction log 56 | 57 | Every interaction that get counted towards the INP metric will be added to the interaction log at the bottom of the panel. 58 | 59 | ![Interaction log in DevTools](media/devtools-interaction-log.png) 60 | 61 | #### Interaction phases 62 | 63 | Phases for each interaction (including the one responsible for INP) are available by expanding the entry in the interaction log. Supports input delay, processing duration, and presentation delay. 64 | 65 | ![Interaction phases in the interaction log](media/devtools-interaction-phases.png) 66 | 67 | #### LoAF attribution 68 | 69 | From the expanded interaction log, the "Local duration (ms)" heading will be clickable if there is LoAF attribution data available. Clicking it will log the data to the Console panel. 70 | 71 | ![Additional LoAF attribution logged to the DevTools console from the interaction log](media/devtools-loaf-attribution.png) 72 | 73 | #### Layout shift log 74 | 75 | Adjacent to the interaction log is the layout shift log, which groups coincidental layout shifts into clusters and assigns a score to the cluster. The worst cluster corresponding to the page's CLS score is linked from the CLS section. 76 | 77 | ![Layout shift log in DevTools](media/devtools-layout-shift-log.png) 78 | 79 | #### TTFB 80 | 81 | TTFB is available as a phase of LCP on the live metrics view as well as the trace view under the "LCP by phase" insight. 82 | 83 | ![TTFB in DevTools trace view](media/devtools-ttfb.png) 84 | 85 | #### FCP 86 | 87 | FCP is shown as a marker in the trace view. 88 | 89 | ![FCP marker in DevTools trace view](media/devtools-fcp.png) 90 | 91 |

⚠️ Self-maintenance instructions

92 | 93 | If you're unable to migrate your workflow to DevTools for any reason, please let us know by following the instructions in the previous section to file an issue. Until your issue gets resolved, you may prefer to continue using the extension using a local copy. 94 | 95 | To get started, follow the instructions below to [install the extension from source](#install-master). 96 | 97 | To continue using field data powered by the CrUX API, you'll need to provision a new API key tied to a personal Google Cloud project. You can acquire a new key by visiting the [CrUX API docs](https://developer.chrome.com/docs/crux/api#APIKey). It doesn't cost anything to use the API, but rate limiting restrictions will apply. 98 | 99 | To stay up to date with the [web-vitals.js library](https://github.com/GoogleChrome/web-vitals), periodically run the following command: 100 | 101 | ```sh 102 | npm update web-vitals --save 103 | ``` 104 | 105 |

Install from main

106 | 107 | **Google Chrome** 108 | 1. Download this repo as a [ZIP file from GitHub](https://github.com/googlechrome/web-vitals-extension/archive/main.zip). 109 | 1. Unzip the file and you should have a folder named `web-vitals-extension-main`. 110 | 1. In Chrome go to the extensions page (`chrome://extensions`). 111 | 1. Enable Developer Mode. 112 | 1. Drag the `web-vitals-extension-main` folder anywhere on the page to import it (do not delete the folder afterwards). 113 | 114 | ## Usage 115 | 116 | ### Ambient badge 117 | 118 | 119 | 120 | The Ambient Badge helps check if a page passing the Core Web Vitals thresholds. 121 | 122 | Once installed, the extension will display a disabled state badge icon until you navigate to a URL. At this point it will update the badge to green, amber or red depending on whether the URL passes the Core Web Vitals metrics thresholds. 123 | 124 | The badge has a number of states: 125 | 126 | * Disabled - gray square 127 | * Good - green circle 128 | * One or more metrics needs improvement - amber square 129 | * One or more metrics poor - red triangle 130 | 131 | If one or more metrics are failing, the badge will animate the values of these metrics (this animation can be turned off in the options screen). 132 | 133 | ### Detailed drill-down 134 | 135 | 136 | 137 | Clicking the Ambient badge icon will allow you to drill in to the individual metric values. In this mode, the extension will also say if a metric requires a user action. 138 | 139 | For example, Interaction to Next Paint requires a real interaction (e.g click/tap) with the page and will be in a `Waiting for input...` state until this is the case. We recommend consulting the web.dev documentation for [LCP](https://web.dev/articles/lcp), [CLS](https://web.dev/articles/cls), and [INP](https://web.dev/articles/inp) to get an understanding of when metric values settle. 140 | 141 | The popup combines your local Core Web Vitals experiences with real-user data from the field via the [Chrome UX Report](https://developer.chrome.com/docs/crux) (CrUX) [API](https://developer.chrome.com/docs/crux/api). This integration gives you contextual insights to help you understand how similar your individual experiences are to other desktop users on the same page. We've also added a new option to "Compare local experiences to phone field data" instead, if needed. Note that CrUX data may not be available for some pages, in which case we try to load field data for the origin as a whole. 142 | 143 | 144 | 145 | ### Overlay 146 | 147 | 148 | 149 | The overlay displays a Heads up display (HUD) which overlays your page. It is useful if you need a persistent view of your Core Web Vitals metrics during development. To enable the overlay: 150 | 151 | * Right-click on the Ambient badge and go to Options. 152 | * Check `Display HUD overlay` and click 'Save' 153 | * Reload the tab for the URL you wish to test. The overlay should now be present. 154 | 155 | ### Console logs 156 | 157 | 158 | 159 | The console logging feature of the Web Vitals extension provides a diagnostic view of all supported metrics. To enable console logs: 160 | 161 | * Right-click on the Ambient badge and go to Options. 162 | * Check `Console logging` and click 'Save' 163 | * Open the Console panel in DevTools and filter for `Web Vitals` 164 | 165 | To filter out unneeded metrics, prepend a minus sign to the metric name. For example, set the filter to `Web Vitals Extension -CLS -LCP` to filter out CLS and LCP diagnostic info. 166 | 167 | Diagnostic info for each metric is logged as a console group prepended by the extension name, `[Web Vitals Extension]`, meaning that you will need to click this line in order to toggle the group open and closed. 168 | 169 | The kinds of diagnostic info varies per metric. For example, the LCP info includes: 170 | 171 | * A reference to the LCP element 172 | * A table of [LCP sub-part metrics](https://web.dev/articles/optimize-lcp#lcp_breakdown) 173 | * An optional warning if the tab was [loaded in the background](https://web.dev/articles/lcp#lcp-background) 174 | * The full attribution object from [web-vitals](https://github.com/GoogleChrome/web-vitals#attribution) 175 | 176 | 177 | ### User Timings 178 | 179 | For some metrics (LCP and INP) the breakdowns can be saved to User Timing marks, using `performance.measure` which are then [viewable in DevTools Performance traces](https://developer.chrome.com/docs/devtools/performance-insights/#timings). 180 | 181 | For the other metrics, Chrome DevTools normally provides sufficient information so additional breakdowns are not necessary. 182 | 183 | ## Contributing 184 | 185 | Contributions to this project are welcome in the form of pull requests or issues. See [CONTRIBUTING.md](/CONTRIBUTING.md) for further details. 186 | 187 | If your feedback is related to how we measure metrics, please file an issue against [web-vitals](https://github.com/GoogleChrome/web-vitals) directly. 188 | 189 | ### How is the extension code structured? 190 | 191 | * `src/browser_action/vitals.js`: Script that leverages WebVitals.js to collect metrics and broadcast metric changes for badging and the HUD. Provides an overall score of the metrics that can be used for badging. 192 | * `src/bg/background.js`: Performs badge icon updates using data provided by vitals.js. Passes along 193 | data to `popup.js` in order to display the more detailed local metrics summary. 194 | * `src/browser_action/popup.js`: Content Script that handles rendering detailed metrics reports in the pop-up window displayed when clicking the badge icon. 195 | * `src/options/options.js`: Options UI (saved configuration) for advanced features like the HUD Overlay 196 | 197 | ## FAQ 198 | 199 | **Who is the primary audience for this extension?** 200 | 201 | The primary audience for this extension is developers who would like instant feedback on how their pages are doing on the Core Web Vitals metrics during development on a desktop machine. 202 | 203 | **How should I interpret the metrics numbers reported by this tool?** 204 | 205 | This extension reports metrics for your desktop or laptop machine. In many cases this hardware will be significantly faster than that of the median mobile phone your users may have. For this reason, it is strongly recommended that you test using tools like [Lighthouse](https://developers.google.com/web/tools/lighthouse/) and on real mobile hardware (e.g via [WebPageTest](https://webpagetest.org/easy)) to ensure all your users there have a positive experience. 206 | 207 | **What actions can I take to improve my Core Web Vitals?** 208 | 209 | We are making available a set of guides for optimizing each of the Core Web Vitals metrics if you find your page is not passing a particular threshold: 210 | 211 | * [Optimize CLS](https://web.dev/articles/optimize-cls) 212 | * [Optimize LCP](https://web.dev/articles/optimize-lcp) 213 | * [Optimize INP](https://web.dev/articles/optimize-inp) 214 | * [Optimize TTFB](https://web.dev/articles/optimize-ttfb) 215 | 216 | Lighthouse also includes additional actionability audits for these metrics. 217 | 218 | We envision users will use the extension for instant feedback on metrics (for their desktop machine) but will then go and do a Lighthouse audit for (1) a diagnostic view of how these metrics look on a median mobile device and (2) specifically what you can do to improve. 219 | 220 | ## License 221 | 222 | [Apache 2.0](/LICENSE) 223 | -------------------------------------------------------------------------------- /icons/default128w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default128w.png -------------------------------------------------------------------------------- /icons/default16w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default16w.png -------------------------------------------------------------------------------- /icons/default256w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default256w.png -------------------------------------------------------------------------------- /icons/default48w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default48w.png -------------------------------------------------------------------------------- /icons/default512w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/default512w.png -------------------------------------------------------------------------------- /icons/fast128w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast128w.png -------------------------------------------------------------------------------- /icons/fast16w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast16w.png -------------------------------------------------------------------------------- /icons/fast256w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast256w.png -------------------------------------------------------------------------------- /icons/fast48w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast48w.png -------------------------------------------------------------------------------- /icons/fast512w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/fast512w.png -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/icon19.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/icon48.png -------------------------------------------------------------------------------- /icons/slow128w-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-3.png -------------------------------------------------------------------------------- /icons/slow128w-cls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-cls.png -------------------------------------------------------------------------------- /icons/slow128w-fid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-fid.png -------------------------------------------------------------------------------- /icons/slow128w-inp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-inp.png -------------------------------------------------------------------------------- /icons/slow128w-lcp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w-lcp.png -------------------------------------------------------------------------------- /icons/slow128w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow128w.png -------------------------------------------------------------------------------- /icons/slow16w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow16w.png -------------------------------------------------------------------------------- /icons/slow256w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow256w.png -------------------------------------------------------------------------------- /icons/slow48w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow48w.png -------------------------------------------------------------------------------- /icons/slow512w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/slow512w.png -------------------------------------------------------------------------------- /icons/vitals128w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/vitals128w.png -------------------------------------------------------------------------------- /icons/vitals256w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/vitals256w.png -------------------------------------------------------------------------------- /icons/vitals48w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/vitals48w.png -------------------------------------------------------------------------------- /icons/vitals512w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/icons/vitals512w.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Web Vitals", 3 | "version": "1.6.0", 4 | "manifest_version": 3, 5 | "description": "Measure metrics for a healthy site", 6 | "homepage_url": "https://web.dev/articles/vitals", 7 | "icons": { 8 | "128": "icons/vitals128w.png", 9 | "256": "icons/vitals256w.png", 10 | "512": "icons/vitals512w.png" 11 | }, 12 | "action": { 13 | "default_icon": "icons/default256w.png", 14 | "default_title": "Web Vitals", 15 | "default_popup": "src/browser_action/popup.html" 16 | }, 17 | "options_page": "src/options/options.html", 18 | "host_permissions": ["*://*/*"], 19 | "permissions": ["tabs", "storage", "activeTab", "scripting"], 20 | "web_accessible_resources": [ 21 | { 22 | "resources": [ 23 | "src/browser_action/viewer.css", 24 | "src/browser_action/web-vitals.js", 25 | "src/browser_action/on-each-interaction.js" 26 | ], 27 | "matches": ["*://*/*"] 28 | } 29 | ], 30 | "content_scripts": [ 31 | { 32 | "matches": ["*://*/*"], 33 | "css": ["src/browser_action/viewer.css"], 34 | "js": ["src/browser_action/lodash-debounce-custom.js"] 35 | } 36 | ], 37 | "background": { 38 | "service_worker": "service_worker.js", 39 | "type": "module" 40 | }, 41 | "content_security_policy": { 42 | "extension_pages": "default-src 'self'; connect-src https://chromeuxreport.googleapis.com;" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /media/cwv-extension-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-badge.png -------------------------------------------------------------------------------- /media/cwv-extension-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-console.png -------------------------------------------------------------------------------- /media/cwv-extension-drilldown-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-drilldown-2.png -------------------------------------------------------------------------------- /media/cwv-extension-drilldown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-drilldown.png -------------------------------------------------------------------------------- /media/cwv-extension-overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-overlay.png -------------------------------------------------------------------------------- /media/cwv-extension-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/cwv-extension-screenshot.png -------------------------------------------------------------------------------- /media/devtools-fcp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-fcp.png -------------------------------------------------------------------------------- /media/devtools-field-data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-field-data.png -------------------------------------------------------------------------------- /media/devtools-interaction-log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-interaction-log.png -------------------------------------------------------------------------------- /media/devtools-interaction-phases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-interaction-phases.png -------------------------------------------------------------------------------- /media/devtools-layout-shift-log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-layout-shift-log.png -------------------------------------------------------------------------------- /media/devtools-lcp-attribution.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-lcp-attribution.png -------------------------------------------------------------------------------- /media/devtools-lcp-phases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-lcp-phases.png -------------------------------------------------------------------------------- /media/devtools-live-metrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-live-metrics.png -------------------------------------------------------------------------------- /media/devtools-loaf-attribution.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-loaf-attribution.png -------------------------------------------------------------------------------- /media/devtools-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-panel.png -------------------------------------------------------------------------------- /media/devtools-ttfb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChrome/web-vitals-extension/b0e745a68228bb0a84f6909d679436f8cca22849/media/devtools-ttfb.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-vitals-extension", 3 | "version": "1.6.0", 4 | "description": "Instant Web Vitals metrics", 5 | "main": "src/browser_action/vitals.js", 6 | "repository": "https://github.com/GoogleChrome/web-vitals-extension", 7 | "homepage": "https://web.dev/articles/vitals", 8 | "bugs": { 9 | "url": "https://github.com/GoogleChrome/web-vitals-extension/issues" 10 | }, 11 | "author": "Google Chrome", 12 | "license": "Apache-2.0", 13 | "private": true, 14 | "scripts": { 15 | "lint": "npx eslint src --fix", 16 | "build": "npm install; cp node_modules/web-vitals/dist/web-vitals.attribution.js src/browser_action/web-vitals.js" 17 | }, 18 | "devDependencies": { 19 | "babel-eslint": "^10.1.0", 20 | "eslint": "^6.8.0", 21 | "eslint-config-google": "^0.14.0" 22 | }, 23 | "dependencies": { 24 | "web-vitals": "^4.2.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /service_worker.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Google Inc. All Rights Reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | const ONE_DAY_MS = 24 * 60 * 60 * 1000; 15 | 16 | // Get the optionsNoBadgeAnimation value 17 | // Actual default is false but lets set to true initially in case sync storage 18 | // is slow so users don't experience any animation initially. 19 | let optionsNoBadgeAnimation = true; 20 | chrome.storage.sync.get({ 21 | noBadgeAnimation: false 22 | }, ({noBadgeAnimation}) => { 23 | optionsNoBadgeAnimation = noBadgeAnimation; 24 | }); 25 | 26 | /** 27 | * Hash the URL and return a numeric hash as a String to be used as the key 28 | * @param {String} str 29 | * @return {String} hash 30 | */ 31 | function hashCode(str) { 32 | let hash = 0; 33 | if (str.length === 0) { 34 | return ''; 35 | } 36 | for (let i = 0; i < str.length; i++) { 37 | const char = str.charCodeAt(i); 38 | hash = (hash << 5) - hash + char; 39 | hash = hash & hash; // Convert to 32bit integer 40 | } 41 | return hash.toString(); 42 | } 43 | 44 | function setExtensionErrorMessage(tab, errorMsg) { 45 | const key = hashCode(tab.url); 46 | chrome.storage.local.set({ 47 | [key]: { 48 | type: 'error', 49 | message: errorMsg, 50 | timestamp: new Date().toISOString() 51 | } 52 | }) 53 | } 54 | 55 | /** 56 | * Call vitals.js to begin collecting local WebVitals metrics. 57 | * This will cause the content script to emit an event that kicks off the badging flow. 58 | * @param {Number} tabId 59 | */ 60 | function getWebVitals(tabId) { 61 | chrome.scripting.executeScript({ 62 | target: { tabId: tabId }, 63 | files: ['src/browser_action/vitals.js'], 64 | }, (result) => { 65 | // Catch errors such as "This page cannot be scripted due 66 | // to an ExtensionsSettings policy." 67 | const error = chrome.runtime.lastError; 68 | if (error && error.message) { 69 | console.log(error.message); 70 | chrome.tabs.get(tabId, (tab) => setExtensionErrorMessage(tab, error.message)); 71 | } 72 | }); 73 | } 74 | 75 | // User has navigated to a new URL in a tab 76 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 77 | const tabIdKey = tabId.toString(); 78 | 79 | if (tab.active) { 80 | chrome.storage.local.set({[tabIdKey]: false}); 81 | } else { 82 | chrome.storage.local.set({[tabIdKey]: true}); // tab was loaded in background 83 | } 84 | 85 | if ( 86 | changeInfo.status == 'complete' && 87 | tab.url.startsWith('http') && 88 | tab.active 89 | ) { 90 | getWebVitals(tabId); 91 | } 92 | }); 93 | 94 | // User has made a new or existing tab visible 95 | chrome.tabs.onActivated.addListener(({tabId, windowId}) => { 96 | getWebVitals(tabId); 97 | }); 98 | 99 | 100 | /** 101 | * 102 | * Update the badge icon based on the overall WebVitals 103 | * pass rate (i.e good = green icon, poor = red icon) 104 | * @param {String} badgeCategory - GOOD or POOR 105 | * @param {Number} tabid 106 | */ 107 | function badgeOverallPerf(badgeCategory, tabid) { 108 | chrome.tabs.query({ 109 | active: true, 110 | currentWindow: true, 111 | }, function(tabs) { 112 | const currentTab = tabid || tabs[0].id; 113 | 114 | switch (badgeCategory) { 115 | case 'POOR': 116 | chrome.action.setIcon({ 117 | path: '../../icons/slow128w.png', 118 | tabId: currentTab, 119 | }); 120 | chrome.action.setBadgeText({ 121 | text: '', 122 | tabId: currentTab, 123 | }); 124 | break; 125 | case 'GOOD': 126 | chrome.action.setIcon({ 127 | path: '../../icons/fast128w.png', 128 | tabId: currentTab, 129 | }); 130 | break; 131 | default: 132 | chrome.action.setIcon({ 133 | path: '../../icons/default128w.png', 134 | tabId: currentTab, 135 | }); 136 | chrome.action.setBadgeText({ 137 | text: '', 138 | tabId: currentTab, 139 | }); 140 | break; 141 | } 142 | }); 143 | } 144 | 145 | /** 146 | * 147 | * Badge the icon for a specific metric 148 | * @param {String} metric 149 | * @param {Number} value 150 | * @param {Number} tabid 151 | */ 152 | function badgeMetric(metric, value, rating, tabid) { 153 | chrome.tabs.query({ 154 | active: true, 155 | currentWindow: true, 156 | }, function(tabs) { 157 | const currentTab = tabid || tabs[0].id; 158 | const bgColor = '#000'; 159 | 160 | // If URL is overall failing the thresholds, only show 161 | // a red badge for metrics actually failing (issues/22) 162 | if (metric === 'lcp' && rating === 'good') { 163 | return; 164 | } 165 | if (metric === 'cls' && rating === 'good') { 166 | return; 167 | } 168 | if (metric === 'inp' && (rating === 'good' || rating === null)) { 169 | return; 170 | } 171 | 172 | switch (metric) { 173 | case 'lcp': 174 | chrome.action.setIcon({ 175 | path: '../../icons/slow128w-lcp.png', 176 | tabId: currentTab, 177 | }); 178 | chrome.action.setBadgeBackgroundColor({ 179 | color: bgColor, 180 | tabId: currentTab, 181 | }); 182 | chrome.action.setBadgeText({ 183 | text: (value / 1000).toFixed(2), 184 | tabId: currentTab, 185 | }); 186 | break; 187 | case 'cls': 188 | chrome.action.setIcon({ 189 | path: '../../icons/slow128w-cls.png', 190 | tabId: currentTab, 191 | }); 192 | chrome.action.setBadgeBackgroundColor({ 193 | color: bgColor, 194 | tabId: currentTab, 195 | }); 196 | chrome.action.setBadgeText({ 197 | text: (value).toFixed(2), 198 | tabId: currentTab, 199 | }); 200 | break; 201 | case 'inp': 202 | chrome.action.setIcon({ 203 | path: '../../icons/slow128w-inp.png', 204 | tabId: currentTab, 205 | }); 206 | chrome.action.setBadgeBackgroundColor({ 207 | color: bgColor, 208 | tabId: currentTab, 209 | }); 210 | chrome.action.setBadgeText({ 211 | text: value.toFixed(0), 212 | tabId: currentTab, 213 | }); 214 | break; 215 | default: 216 | chrome.action.setIcon({ 217 | path: '../../icons/default128w.png', 218 | tabId: currentTab, 219 | }); 220 | chrome.action.setBadgeBackgroundColor({ 221 | color: '', 222 | tabId: currentTab, 223 | }); 224 | chrome.action.setBadgeText({ 225 | text: '', 226 | tabId: currentTab, 227 | }); 228 | break; 229 | } 230 | }); 231 | } 232 | 233 | /** 234 | * Wait ms milliseconds 235 | * 236 | * @param {Number} ms 237 | * @return {Promise} 238 | */ 239 | function wait(ms) { 240 | return new Promise((r) => setTimeout(r, ms)); 241 | } 242 | 243 | /** 244 | * @param {number} tabId 245 | * @return {Promise} 246 | */ 247 | async function doesTabExist(tabId) { 248 | try { 249 | await chrome.tabs.get(tabId); 250 | return true; 251 | } catch (_) { 252 | return false; 253 | } 254 | } 255 | 256 | /** @type {number} */ 257 | let globalAnimationId = 0; 258 | /** @type {Map} */ 259 | const animationsByTabId = new Map(); 260 | 261 | 262 | /** 263 | * Animate badges between pass/fail -> each failing metric. 264 | * We track each animation by tabId so that we can handle "cancellation" of the animation on new information. 265 | * @param {Object} request 266 | * @param {Number} tabId 267 | */ 268 | async function animateBadges(request, tabId) { 269 | const animationId = globalAnimationId; 270 | animationsByTabId.set(tabId, animationId); 271 | globalAnimationId++; 272 | 273 | const delay = 2000; 274 | // First badge overall perf 275 | badgeOverallPerf(request.passesAllThresholds, tabId); 276 | 277 | // If perf is poor, animate the sequence 278 | if (request.passesAllThresholds === 'POOR') { 279 | 280 | // However, if user has turned this off, then leave it off. 281 | // Note: if optionsNoBadgeAnimation is flipped, it won't start (or stop) 282 | // animating immediately until a status change or page reload to avoid 283 | // having to check continually. This is similar to HUD and console.logs 284 | // not appearing immediately. 285 | if (optionsNoBadgeAnimation) { 286 | return; 287 | } 288 | 289 | await wait(delay); 290 | if (animationsByTabId.get(tabId) !== animationId) return; 291 | badgeMetric('lcp', request.metrics.lcp.value, request.metrics.lcp.rating, tabId); 292 | 293 | await wait(delay); 294 | if (animationsByTabId.get(tabId) !== animationId) return; 295 | badgeMetric('inp', request.metrics.inp.value, request.metrics.inp.rating, tabId); 296 | 297 | await wait(delay); 298 | if (animationsByTabId.get(tabId) !== animationId) return; 299 | badgeMetric('cls', request.metrics.cls.value, request.metrics.cls.rating, tabId); 300 | 301 | // Loop the animation if no new information came in while we animated. 302 | await wait(delay); 303 | if (animationsByTabId.get(tabId) !== animationId) return; 304 | // Stop animating if the tab is gone 305 | if (!(await doesTabExist(tabId))) return; 306 | animateBadges(request, tabId); 307 | } 308 | } 309 | 310 | // message from content script 311 | chrome.runtime.onConnect.addListener((port) => { 312 | port.onMessage.addListener((request) => { 313 | if (request.passesAllThresholds !== undefined) { 314 | // e.g passesAllThresholds === 'GOOD' => green badge 315 | animateBadges(request, port.sender.tab.id); 316 | // Store latest metrics locally only. 317 | // The popup will load the metric values from this storage. 318 | if (port.sender.tab.url) { 319 | const key = hashCode(port.sender.tab.url); 320 | chrome.storage.local.set({[key]: request.metrics}); 321 | } 322 | // send TabId to content script 323 | port.postMessage({tabId: port.sender.tab.id}); 324 | } 325 | }); 326 | }); 327 | 328 | // Listen for changes to noBadgeAnimation option 329 | function logStorageChange(changes, area) { 330 | if (area === 'sync' && 'noBadgeAnimation' in changes) { 331 | optionsNoBadgeAnimation = changes.noBadgeAnimation.newValue; 332 | } 333 | } 334 | chrome.storage.onChanged.addListener(logStorageChange); 335 | 336 | 337 | async function clearOldCacheBackground(tabId) { 338 | if (!(await doesTabExist(tabId))) { 339 | chrome.storage.local.remove([tabId]); 340 | }; 341 | } 342 | 343 | async function clearOldCache() { 344 | const now = Date.now(); 345 | chrome.storage.local.get(null, results => { 346 | for (let hash in results) { 347 | if (results[hash].timestamp) { 348 | // If it's a timestamp, check if still valid 349 | const timestamp = new Date(results[hash].timestamp).getTime(); 350 | if (now - timestamp > ONE_DAY_MS ) { 351 | console.log('Removing', hash, results[hash]); 352 | chrome.storage.local.remove([hash]); 353 | } 354 | } else if (typeof results[hash] === 'boolean') { 355 | // If it's a tab background status, clear that separately 356 | clearOldCacheBackground(hash); 357 | } 358 | } 359 | }); 360 | 361 | } 362 | 363 | self.addEventListener('activate', _ => { 364 | clearOldCache(); 365 | }); 366 | 367 | -------------------------------------------------------------------------------- /src/browser_action/browser_action.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/browser_action/chrome.js: -------------------------------------------------------------------------------- 1 | function hashCode(str) { 2 | let hash = 0; 3 | if (str.length == 0) { 4 | return ''; 5 | } 6 | for (let i = 0; i < str.length; i++) { 7 | const char = str.charCodeAt(i); 8 | hash = (hash << 5) - hash + char; 9 | // Convert to 32bit integer 10 | hash = hash & hash; 11 | } 12 | return hash.toString(); 13 | } 14 | 15 | export function loadLocalMetrics() { 16 | return new Promise(resolve => { 17 | chrome.tabs.query({active: true, currentWindow: true}, tabs => { 18 | const thisTab = tabs[0]; 19 | 20 | // Retrieve the stored latest metrics 21 | if (thisTab.url) { 22 | const key = hashCode(thisTab.url); 23 | const loadedInBackgroundKey = thisTab.id.toString(); 24 | 25 | let tabLoadedInBackground = false; 26 | 27 | chrome.storage.local.get(loadedInBackgroundKey, result => { 28 | tabLoadedInBackground = result[loadedInBackgroundKey]; 29 | }); 30 | 31 | chrome.storage.local.get(key, result => { 32 | if (result[key] !== undefined) { 33 | if (result[key].type && result[key].type === 'error') { 34 | // It's an error message, not a metrics object 35 | resolve({error: result[key].message}); 36 | } else { 37 | resolve({ 38 | metrics: result[key], 39 | background: tabLoadedInBackground 40 | }); 41 | } 42 | } else { 43 | resolve({error: `Storage empty for key ${key}: ${result}`}); 44 | } 45 | }); 46 | } 47 | }); 48 | }); 49 | } 50 | 51 | export function getOptions() { 52 | return new Promise(resolve => { 53 | chrome.storage.sync.get({preferPhoneField: false}, resolve); 54 | }); 55 | } 56 | 57 | export function getURL() { 58 | return new Promise(resolve => { 59 | chrome.tabs.query({active: true, lastFocusedWindow: true}, tabs => { 60 | let url = tabs[0].url; 61 | resolve(url); 62 | }); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /src/browser_action/core.css: -------------------------------------------------------------------------------- 1 | .web-vitals-chrome-extension-popup { 2 | width: 560px; 3 | 4 | font-size: 12px; 5 | 6 | --color-good: #0CCE6A; 7 | --color-good-text: #1E8E3E; 8 | --color-good-muted: hsl(149 0% 43% / 0.38); 9 | --color-needs-improvement: #FFA400; 10 | --color-needs-improvement-text: #AE6032; 11 | --color-needs-improvement-muted: hsl(39 0% 50% / 0.38); 12 | --color-poor: #FF4E42; 13 | --color-poor-text: #EB0F00; 14 | --color-poor-muted: hsl(4 0% 63% / 0.38); 15 | --color-text: #3C4043; 16 | --color-text-muted: #5F6368; 17 | --color-text-link: #3740FF; 18 | --color-light-grey: #F8F9FA; 19 | --color-dark-grey: #DADCE0; 20 | --transition-duration-easing: 250ms cubic-bezier(0.4, 0.0, 0.2, 1); 21 | } 22 | 23 | .web-vitals-chrome-extension-popup * { 24 | box-sizing: border-box; 25 | } 26 | 27 | .web-vitals-chrome-extension-popup body { 28 | display: flex; 29 | flex-direction: column; 30 | justify-content: space-between; 31 | margin: 0px; 32 | height: 100%; 33 | 34 | font-size: 1rem; 35 | line-height: 1.17rem; 36 | font-weight: 400; 37 | font-family: Roboto, sans-serif; 38 | color: var(--color-text); 39 | } 40 | 41 | .web-vitals-chrome-extension-popup header { 42 | display: flex; 43 | justify-content: space-between; 44 | align-items: center; 45 | padding: 1rem; 46 | } 47 | 48 | .web-vitals-chrome-extension-popup footer { 49 | padding: 1rem; 50 | } 51 | 52 | .web-vitals-chrome-extension-popup footer > * { 53 | margin-bottom: 0.75rem; 54 | } 55 | .web-vitals-chrome-extension-popup footer > *:last-child { 56 | margin-bottom: 0rem; 57 | } 58 | 59 | .web-vitals-chrome-extension-popup h1 { 60 | margin: 0px; 61 | font-size: 1.5rem; 62 | line-height: 2rem; 63 | font-weight: 500; 64 | font-family: "Google Sans", Roboto, sans-serif; 65 | } 66 | 67 | .web-vitals-chrome-extension-popup a { 68 | color: var(--color-text-link); 69 | text-decoration: none; 70 | } 71 | .web-vitals-chrome-extension-popup a:hover { 72 | text-decoration: underline; 73 | } 74 | 75 | .web-vitals-chrome-extension-popup strong { 76 | font-weight: 500; 77 | } 78 | 79 | .web-vitals-chrome-extension-popup .hidden { 80 | display: none; 81 | } 82 | 83 | .web-vitals-chrome-extension-popup .nowrap { 84 | white-space: nowrap; 85 | } 86 | 87 | .web-vitals-chrome-extension-popup .metadata { 88 | color: var(--color-text-muted); 89 | letter-spacing: 0.2px; 90 | } 91 | 92 | .web-vitals-chrome-extension-popup #metadata { 93 | display: flex; 94 | flex-direction: row; 95 | align-items: center; 96 | min-height: 2.34rem; 97 | } 98 | 99 | .web-vitals-chrome-extension-popup #status { 100 | text-align: right; 101 | } 102 | 103 | .web-vitals-chrome-extension-popup .info { 104 | margin-left: 1rem; 105 | text-indent: 0rem; 106 | } 107 | .web-vitals-chrome-extension-popup .info svg { 108 | fill: currentColor; 109 | } 110 | 111 | .web-vitals-chrome-extension-popup .metric-wrapper { 112 | padding: 0rem 1rem; 113 | } 114 | 115 | .web-vitals-chrome-extension-popup .metric-wrapper:hover { 116 | background-color: var(--color-light-grey); 117 | } 118 | 119 | .web-vitals-chrome-extension-popup .metric { 120 | display: grid; 121 | grid-template-areas: "metric-info metric-performance"; 122 | grid-template-columns: 1fr 330px; 123 | align-items: center; 124 | border-bottom: 1px solid var(--color-dark-grey); 125 | height: 6.2rem; 126 | } 127 | .web-vitals-chrome-extension-popup .metric-wrapper:last-child .metric { 128 | border-bottom: none; 129 | } 130 | 131 | .web-vitals-chrome-extension-popup .metric-info { 132 | display: flex; 133 | align-items: baseline; 134 | grid-area: metric-info; 135 | } 136 | 137 | .web-vitals-chrome-extension-popup .metric-name { 138 | display: inline-block; 139 | font-size: 1.17rem; 140 | line-height: 1.67rem; 141 | white-space: nowrap; 142 | } 143 | 144 | .web-vitals-chrome-extension-popup .metric-performance { 145 | position: relative; 146 | } 147 | 148 | .web-vitals-chrome-extension-popup .metric-performance-local { 149 | margin-bottom: 0.33rem; 150 | white-space: nowrap; 151 | } 152 | 153 | .web-vitals-chrome-extension-popup .metric-performance-local-value-wrapper { 154 | display: inline-flex; 155 | } 156 | 157 | .web-vitals-chrome-extension-popup .metric-performance-local-value { 158 | display: inline-block; 159 | } 160 | 161 | /* If the pin is too far right, put the label before it. */ 162 | .web-vitals-chrome-extension-popup .reversed .metric-performance-local-value-wrapper { 163 | flex-direction: row-reverse; 164 | transform: translateX(calc(-100% - 2rem)); 165 | } 166 | 167 | .web-vitals-chrome-extension-popup .good .metric-performance-local, 168 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local, 169 | .web-vitals-chrome-extension-popup .poor .metric-performance-local { 170 | transition: margin-left var(--transition-duration-easing); 171 | position: relative; 172 | font-size: 1.17rem; 173 | line-height: 1.67rem; 174 | font-weight: 500; 175 | text-indent: 1rem; 176 | } 177 | .web-vitals-chrome-extension-popup .good .metric-performance-local { 178 | color: var(--color-good-text); 179 | } 180 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local { 181 | color: var(--color-needs-improvement-text); 182 | } 183 | .web-vitals-chrome-extension-popup .poor .metric-performance-local { 184 | color: var(--color-poor-text); 185 | } 186 | 187 | /* Pinpoint */ 188 | .web-vitals-chrome-extension-popup .good .metric-performance-local::before, 189 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local::before, 190 | .web-vitals-chrome-extension-popup .poor .metric-performance-local::before { 191 | position: absolute; 192 | top: 1rem; 193 | left: -2px; 194 | content: ''; 195 | width: 1px; 196 | height: 1.17rem; 197 | border: 2px solid #fff; 198 | border-bottom: 0; 199 | } 200 | .web-vitals-chrome-extension-popup .good .metric-performance-local::before { 201 | background-color: var(--color-good); 202 | } 203 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local::before { 204 | background-color: var(--color-needs-improvement); 205 | } 206 | .web-vitals-chrome-extension-popup .poor .metric-performance-local::before { 207 | background-color: var(--color-poor); 208 | } 209 | .web-vitals-chrome-extension-popup .good:hover .metric-performance-local::before, 210 | .web-vitals-chrome-extension-popup .needs-improvement:hover .metric-performance-local::before, 211 | .web-vitals-chrome-extension-popup .poor:hover .metric-performance-local::before { 212 | border-color: var(--color-light-grey); 213 | } 214 | 215 | /* Pinhead*/ 216 | .web-vitals-chrome-extension-popup .good .metric-performance-local::after, 217 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local::after, 218 | .web-vitals-chrome-extension-popup .poor .metric-performance-local::after { 219 | position: absolute; 220 | top: 0.33rem; 221 | left: 0rem; 222 | content: ''; 223 | display: inline-block; 224 | width: var(--pinhead-size); 225 | height: var(--pinhead-size); 226 | transform: translateX(calc((var(--pinhead-size) - 1px) / -2)); 227 | 228 | --pinhead-size: calc(5 / 6 * 1rem + 1px); 229 | } 230 | .web-vitals-chrome-extension-popup .good .metric-performance-local::after { 231 | /* Circle */ 232 | border-radius: 100%; 233 | background-color: var(--color-good); 234 | transform: translateX(calc((var(--pinhead-size) - 2px) / -2)); 235 | } 236 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-local::after { 237 | /* Square (default) */ 238 | background-color: var(--color-needs-improvement); 239 | } 240 | .web-vitals-chrome-extension-popup .poor .metric-performance-local::after { 241 | /* Triangle */ 242 | height: 0rem; 243 | width: 0rem; 244 | border-left: calc(var(--pinhead-size) / 2) solid transparent; 245 | border-right: calc(var(--pinhead-size) / 2) solid transparent; 246 | border-bottom: var(--pinhead-size) solid var(--color-poor); 247 | } 248 | 249 | .web-vitals-chrome-extension-popup .metric-performance { 250 | grid-area: metric-performance; 251 | display: flex; 252 | flex-direction: column; 253 | } 254 | 255 | .web-vitals-chrome-extension-popup .metric-performance-distribution { 256 | display: flex; 257 | } 258 | 259 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating { 260 | transition: width var(--transition-duration-easing); 261 | width: var(--rating-width, 33.33%); 262 | min-width: var(--min-rating-width, 0%); 263 | margin-right: 0.17rem; 264 | border-top: 4px solid; 265 | 266 | /* Hide the % when the density is too small. */ 267 | box-sizing: content-box; 268 | max-height: 1rem; 269 | word-break: break-word; 270 | overflow: hidden; 271 | } 272 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating:last-child { 273 | margin-right: 0rem; 274 | } 275 | 276 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating.good { 277 | border-top-color: var(--color-good-muted); 278 | } 279 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating.needs-improvement { 280 | border-top-color: var(--color-needs-improvement-muted); 281 | } 282 | .web-vitals-chrome-extension-popup .metric-performance-distribution-rating.poor { 283 | border-top-color: var(--color-poor-muted); 284 | text-align: right; 285 | } 286 | 287 | .web-vitals-chrome-extension-popup .good .metric-performance-distribution-rating.good, 288 | .web-vitals-chrome-extension-popup .metric-wrapper:hover .metric-performance-distribution-rating.good { 289 | border-top-color: var(--color-good); 290 | } 291 | .web-vitals-chrome-extension-popup .needs-improvement .metric-performance-distribution-rating.needs-improvement, 292 | .web-vitals-chrome-extension-popup .metric-wrapper:hover .metric-performance-distribution-rating.needs-improvement { 293 | border-top-color: var(--color-needs-improvement); 294 | } 295 | .web-vitals-chrome-extension-popup .poor .metric-performance-distribution-rating.poor, 296 | .web-vitals-chrome-extension-popup .metric-wrapper:hover .metric-performance-distribution-rating.poor { 297 | border-top-color: var(--color-poor); 298 | } 299 | 300 | .web-vitals-chrome-extension-popup .hovercard { 301 | transition: visibility 0s var(--hovercard-hide-delay), opacity 0s var(--hovercard-hide-delay); 302 | visibility: hidden; 303 | opacity: 0; 304 | position: absolute; 305 | top: calc(100% + 1rem); 306 | left: 0px; 307 | z-index: 1; 308 | width: 100%; 309 | box-shadow: 0px 4px 8px rgba(60, 64, 67, 0.15), 0px 1px 3px rgba(60, 64, 67, 0.3); 310 | border: 1px solid var(--color-dark-grey); 311 | border-radius: 3px; 312 | padding: 1.33rem 1rem; 313 | background-color: #fff; 314 | color: var(--color-text); 315 | font-size: 1.17rem; 316 | line-height: 1.67rem; 317 | 318 | --hovercard-show-delay: 400ms; 319 | --hovercard-hide-delay: 0ms; 320 | } 321 | .web-vitals-chrome-extension-popup .metric-wrapper:hover.good .hovercard, 322 | .web-vitals-chrome-extension-popup .metric-wrapper:hover.needs-improvement .hovercard, 323 | .web-vitals-chrome-extension-popup .metric-wrapper:hover.poor .hovercard { 324 | visibility: visible; 325 | opacity: 1; 326 | transition-delay: var(--hovercard-show-delay); 327 | } 328 | .web-vitals-chrome-extension-popup .metric-wrapper:last-child .hovercard { 329 | top: unset; 330 | bottom: calc(100% + 1rem); 331 | } 332 | .web-vitals-chrome-extension-popup .hovercard::before { 333 | position: absolute; 334 | content: ''; 335 | bottom: calc(100% + var(--border-width)); 336 | left: 50%; 337 | width: var(--pointer-size); 338 | height: var(--pointer-size); 339 | box-shadow: 6px 6px 0px 6px var(--background-color), 0px 0px 0px var(--border-width) var(--color-dark-grey), 4px 4px 8px rgba(60, 64, 67, 0.15), 1px 1px 3px rgba(60, 64, 67, 0.3); 340 | background-color: var(--background-color); 341 | transform: translateX(-50%) translateY(calc(var(--pointer-size) / 2 - var(--border-width))) rotate(45deg); 342 | 343 | --pointer-size: 1rem; 344 | --border-width: 1px; 345 | --background-color: #fff; 346 | } 347 | .web-vitals-chrome-extension-popup .metric-wrapper:last-child .hovercard::before { 348 | bottom: unset; 349 | top: calc(100% + var(--border-width)); 350 | box-shadow: -3px -3px 0px 3px var(--background-color), 0px 0px 0px var(--border-width) var(--color-dark-grey), 4px 4px 8px rgba(60, 64, 67, 0.15), 1px 1px 3px rgba(60, 64, 67, 0.3); 351 | transform: translateX(-50%) translateY(calc(var(--pointer-size) / -2)) rotate(45deg); 352 | } 353 | .web-vitals-chrome-extension-popup .good .hovercard-local { 354 | color: var(--color-good-text); 355 | } 356 | .web-vitals-chrome-extension-popup .needs-improvement .hovercard-local { 357 | color: var(--color-needs-improvement-text); 358 | } 359 | .web-vitals-chrome-extension-popup .poor .hovercard-local { 360 | color: var(--color-poor-text); 361 | } 362 | 363 | .web-vitals-chrome-extension-popup #device-page-wrapper { 364 | white-space: nowrap; 365 | overflow: hidden; 366 | text-overflow: ellipsis; 367 | } 368 | 369 | .web-vitals-chrome-extension-popup #footer-wrapper { 370 | display:flex; 371 | justify-content:space-between; 372 | } 373 | 374 | .web-vitals-chrome-extension-popup #device-icon-desktop, 375 | .web-vitals-chrome-extension-popup #device-icon-phone { 376 | display: none; 377 | fill: currentColor; 378 | vertical-align: middle; 379 | } 380 | .web-vitals-chrome-extension-popup .device-desktop #device-icon-desktop { 381 | display: inline-block; 382 | } 383 | .web-vitals-chrome-extension-popup .device-phone #device-icon-phone { 384 | display: inline-block; 385 | } 386 | 387 | .web-vitals-chrome-extension-popup #page { 388 | vertical-align: middle; 389 | } 390 | 391 | .web-vitals-chrome-extension-popup #settings-link a svg { 392 | color: black; 393 | } 394 | 395 | .web-vitals-chrome-extension-popup #settings-link a:hover svg { 396 | color: blue; 397 | } 398 | 399 | .web-vitals-chrome-extension-popup #eol-notice { 400 | border: 4px solid var(--color-needs-improvement); 401 | margin: auto 20px; 402 | padding: 0 30px; 403 | font-size: 1.2rem; 404 | line-height: 1.8rem; 405 | } 406 | .web-vitals-chrome-extension-popup #eol-notice::backdrop { 407 | backdrop-filter: blur(1px); 408 | } 409 | .web-vitals-chrome-extension-popup button.danger { 410 | background-color: var(--color-needs-improvement); 411 | border: none; 412 | } 413 | .web-vitals-chrome-extension-popup #eol-notice > :first-child { 414 | margin-top: 30px; 415 | } 416 | .web-vitals-chrome-extension-popup #eol-notice > :last-child { 417 | margin-bottom: 30px; 418 | } 419 | -------------------------------------------------------------------------------- /src/browser_action/crux.js: -------------------------------------------------------------------------------- 1 | import { Metric } from './metric.js'; 2 | 3 | // This key only works from the Web Vitals extension. 4 | const CRUX_API_KEY = 'AIzaSyCZKhcAeiqGCp34891LPqVteT5kUMMq1og'; 5 | 6 | export class CrUX { 7 | 8 | static load(pageUrl, formFactor) { 9 | const urlHelper = new URL(pageUrl); 10 | const url = urlHelper.href; 11 | const origin = urlHelper.origin; 12 | 13 | return CrUX.query({url, formFactor}).catch(e =>{ 14 | // If URL data is unavailable, fall back to the origin. 15 | return CrUX.query({origin, formFactor}); 16 | }); 17 | } 18 | 19 | static query(request) { 20 | const ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${CRUX_API_KEY}`; 21 | return fetch(ENDPOINT, { 22 | method: 'POST', 23 | body: JSON.stringify(request) 24 | }).then(response => { 25 | return response.json(); 26 | }).then(response => { 27 | if (response.error) { 28 | return Promise.reject(response); 29 | } 30 | 31 | return response; 32 | }); 33 | } 34 | 35 | static isOriginFallback(response) { 36 | return CrUX.getOrigin(response) !== undefined; 37 | } 38 | 39 | static getOrigin(response) { 40 | return response.record.key.origin; 41 | } 42 | 43 | static getNormalizedUrl(response) { 44 | return response?.urlNormalizationDetails?.normalizedUrl; 45 | } 46 | 47 | static getMetrics(response) { 48 | return Object.entries(response.record.metrics).map(([metricName, data]) => { 49 | return { 50 | id: Metric.mapCruxNameToId(metricName), 51 | data 52 | }; 53 | }); 54 | } 55 | 56 | static getDistribution(data) { 57 | return data.histogram.map(({density}) => density || 0); 58 | } 59 | 60 | static get FormFactor() { 61 | return { 62 | PHONE: 'PHONE', 63 | DESKTOP: 'DESKTOP' 64 | }; 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/browser_action/lodash-debounce-custom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * lodash 3.8.0 (Custom Build) lodash.com/license | Underscore.js 1.8.3 underscorejs.org/LICENSE 4 | * Build: `lodash compat include="debounce" --production -o lodash.compat.js` 5 | */ 6 | ;(function(){function t(){}function e(t){var e=typeof t;return"function"==e||!!t&&"object"==e}function n(t){return null==t?false:g.call(t)==i?m.test(y.call(t)):!!t&&typeof t=="object"&&(d(t)?m:u).test(t)}function o(t){return typeof t!="string"&&(t=null==t?"":t+""),t&&c.test(t)?t.replace(r,"\\$&"):t}var i="[object Function]",r=/[.*+?^${}()|[\]\/\\]/g,c=RegExp(r.source),u=/^\[object .+?Constructor\]$/,l={"function":true,object:true},f=l[typeof exports]&&exports&&!exports.nodeType&&exports,a=l[typeof module]&&module&&!module.nodeType&&module,p=l[typeof self]&&self&&self.Object&&self,s=l[typeof window]&&window&&window.Object&&window,l=a&&a.exports===f&&f,p=f&&a&&typeof global=="object"&&global&&global.Object&&global||s!==(this&&this.window)&&s||p||this,d=function(){ 7 | try{Object({toString:0}+"")}catch(t){return function(){return false}}return function(t){return typeof t.toString!="function"&&typeof(t+"")=="string"}}(),y=Function.prototype.toString,g=Object.prototype.toString,m=RegExp("^"+o(g).replace(/toString|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),b=Math.max,w=n(w=Date.now)&&w,j=w||function(){return(new Date).getTime()};t.debounce=function(t,n,o){function i(){var e=n-(j()-a);0>=e||e>n?(l&&clearTimeout(l),e=d,l=s=d=void 0,e&&(y=j(),f=t.apply(p,u),s||l||(u=p=null))):s=setTimeout(i,e); 8 | 9 | }function r(){s&&clearTimeout(s),l=s=d=void 0,(m||g!==n)&&(y=j(),f=t.apply(p,u),s||l||(u=p=null))}function c(){if(u=arguments,a=j(),p=this,d=m&&(s||!w),false===g)var e=w&&!s;else{l||w||(y=a);var o=g-(a-y),c=0>=o||o>g;c?(l&&(l=clearTimeout(l)),y=a,f=t.apply(p,u)):l||(l=setTimeout(r,o))}return c&&s?s=clearTimeout(s):s||n===g||(s=setTimeout(i,n)),e&&(c=true,f=t.apply(p,u)),!c||s||l||(u=p=null),f}var u,l,f,a,p,s,d,y=0,g=false,m=true;if(typeof t!="function")throw new TypeError("Expected a function");if(n=0>n?0:+n||0, 10 | !0===o)var w=true,m=false;else e(o)&&(w=o.leading,g="maxWait"in o&&b(+o.maxWait||0,n),m="trailing"in o?o.trailing:m);return c.cancel=function(){s&&clearTimeout(s),l&&clearTimeout(l),l=s=d=void 0},c},t.escapeRegExp=o,t.isNative=n,t.isObject=e,t.now=j,t.VERSION="3.8.0",typeof define=="function"&&typeof define.amd=="object"&&define.amd?(p._=t, define(function(){return t})):f&&a?l?(a.exports=t)._=t:f._=t:p._=t}).call(this); -------------------------------------------------------------------------------- /src/browser_action/metric.js: -------------------------------------------------------------------------------- 1 | import {CLSThresholds, FCPThresholds, INPThresholds, LCPThresholds, TTFBThresholds} from './web-vitals.js'; 2 | 3 | const assessments = { 4 | 'good': 0, 5 | 'needs-improvement': 1, 6 | 'poor': 2 7 | }; 8 | 9 | const secondsFormatter = new Intl.NumberFormat(undefined, { 10 | unit: "second", 11 | style: 'unit', 12 | unitDisplay: "short", 13 | minimumFractionDigits: 3, 14 | maximumFractionDigits: 3 15 | }); 16 | 17 | const millisecondsFormatter = new Intl.NumberFormat(undefined, { 18 | unit: "millisecond", 19 | style: 'unit', 20 | unitDisplay: 'short', 21 | minimumFractionDigits: 0, 22 | maximumFractionDigits: 0 23 | }); 24 | 25 | const clsFormatter = new Intl.NumberFormat(undefined, { 26 | unitDisplay: 'short', 27 | minimumFractionDigits: 2, 28 | maximumFractionDigits: 2 29 | }); 30 | 31 | 32 | export class Metric { 33 | 34 | constructor({id, name, local, background, thresholds, rating}) { 35 | this.id = id; 36 | this.abbr = id.toUpperCase(); 37 | this.name = name; 38 | this.local = local; 39 | this.background = background; 40 | this.thresholds = thresholds; 41 | // This will be replaced with field data, if available. 42 | this.distribution = [1/3, 1/3, 1/3]; 43 | this.rating = rating; 44 | } 45 | 46 | formatValue(value) { 47 | return value; 48 | } 49 | 50 | getAssessmentIndex() { 51 | return assessments[this.rating]; 52 | } 53 | 54 | getRelativePosition(value) { 55 | if (!this.thresholds) { 56 | console.warn('Unable to position local value', this, '(no thresholds)'); 57 | return '0%'; 58 | } 59 | 60 | let relativePosition = 0; 61 | const {good, poor} = this.thresholds; 62 | // Densities smaller than this amount are visually insignificant. 63 | const MIN_PCT = this.MIN_PCT; 64 | // The poor bucket is unbounded, so a value can never really be 100%. 65 | const MAX_PCT = 0.95; 66 | // ... but we still need to use something as the upper limit. 67 | const MAX_VALUE = poor * 2.5; 68 | // 69 | let totalDensity = 0; 70 | const [pctGood, pctNeedsImprovement, pctPoor] = this.distribution.map(density => { 71 | // Rating widths aren't affected by MAX_PCT, so we don't adjust for it here. 72 | density = Math.max(density, MIN_PCT); 73 | totalDensity += density; 74 | return density; 75 | }).map(density => density / totalDensity); 76 | 77 | // The relative position is linearly interpolated for simplicity. 78 | if (value < good) { 79 | relativePosition = value * pctGood / good; 80 | } else if (value >= poor) { 81 | relativePosition = Math.min(MAX_PCT, (value - poor) / (poor * MAX_VALUE)) * pctPoor + pctGood + pctNeedsImprovement; 82 | } else { 83 | relativePosition = (value - good) * pctNeedsImprovement / (poor - good) + pctGood; 84 | } 85 | 86 | return `${relativePosition * 100}%`; 87 | } 88 | 89 | getInfo() { 90 | return; 91 | } 92 | 93 | getDensity(i, decimalPlaces=0) { 94 | const density = this.distribution[i]; 95 | 96 | return `${(density * 100).toFixed(decimalPlaces)}%`; 97 | } 98 | 99 | get MIN_PCT() { 100 | return 0.02; 101 | } 102 | 103 | set distribution(distribution) { 104 | const [good, needsImprovement, poor] = distribution; 105 | let total = good + needsImprovement + poor; 106 | if (Math.abs(1 - total) > 0.0001) { 107 | console.warn('Field distribution densities don\'t sum to 100%:', good, needsImprovement, poor); 108 | } 109 | 110 | // Normalize the densities so they always sum to exactly 100%. 111 | // Consider [98.29%, 1.39%, 0.31%]. Naive rounding would produce [98%, 1%, 0%]. 112 | // Since 1.39% lost the most due to rounding (0.39%), we overcompensate by making it 2%. 113 | // This way, the result adds up to 100%: [98%, 2%, 0%]. 114 | 115 | // Sort the indices by those that "have the most to lose" by rounding. 116 | let sortedIndices = distribution.map(i => i - (Math.floor(i * 100) / 100)); 117 | sortedIndices = Array.from(sortedIndices).sort().reverse().map(i => sortedIndices.indexOf(i)); 118 | // Round all densities down to the hundredths place. 119 | // This is expected to change the total to < 1 (underflow). 120 | distribution = distribution.map(density => { 121 | return Math.floor(density * 100 / total) / 100; 122 | }); 123 | // Add 1% back to the densities that "lost the most" until we reach 100%. 124 | total = distribution.reduce((total, i) => total + (i * 100), 0); 125 | for (let i = 0; i < (100 - total); i++) { 126 | const densityIndex = sortedIndices[i]; 127 | distribution[densityIndex] = ((distribution[densityIndex] * 100) + 1) / 100; 128 | } 129 | 130 | this._distribution = distribution; 131 | } 132 | 133 | get distribution() { 134 | return this._distribution; 135 | } 136 | 137 | static mapCruxNameToId(cruxName) { 138 | const nameMap = { 139 | 'largest_contentful_paint': 'lcp', 140 | 'interaction_to_next_paint': 'inp', 141 | 'cumulative_layout_shift': 'cls', 142 | 'first_contentful_paint': 'fcp', 143 | 'experimental_time_to_first_byte': 'ttfb' 144 | }; 145 | 146 | return nameMap[cruxName]; 147 | } 148 | 149 | } 150 | 151 | export class LCP extends Metric { 152 | 153 | constructor({local, background, rating}) { 154 | const thresholds = { 155 | good: LCPThresholds[0], 156 | poor: LCPThresholds[1] 157 | }; 158 | 159 | // TODO(rviscomi): Consider better defaults. 160 | local = local || 0; 161 | rating = rating || 'good'; 162 | 163 | super({ 164 | id: 'lcp', 165 | name: 'Largest Contentful Paint', 166 | local, 167 | background, 168 | thresholds, 169 | rating 170 | }); 171 | } 172 | 173 | formatValue(value) { 174 | value /= 1000; 175 | return secondsFormatter.format(value); 176 | } 177 | 178 | getInfo() { 179 | if (this.background) { 180 | return 'LCP inflated by tab loading in the background'; 181 | } 182 | 183 | return super.getInfo(); 184 | } 185 | 186 | } 187 | 188 | export class INP extends Metric { 189 | 190 | constructor({local, background, rating}) { 191 | const thresholds = { 192 | good: INPThresholds[0], 193 | poor: INPThresholds[1] 194 | }; 195 | 196 | super({ 197 | id: 'inp', 198 | name: 'Interaction to Next Paint', 199 | local, 200 | background, 201 | thresholds, 202 | rating 203 | }); 204 | } 205 | 206 | formatValue(value) { 207 | if (value === null) { 208 | return 'Waiting for input…'; 209 | } 210 | 211 | return millisecondsFormatter.format(value); 212 | } 213 | 214 | } 215 | 216 | export class CLS extends Metric { 217 | 218 | constructor({local, background, rating}) { 219 | const thresholds = { 220 | good: CLSThresholds[0], 221 | poor: CLSThresholds[1] 222 | }; 223 | 224 | // TODO(rviscomi): Consider better defaults. 225 | local = local || 0; 226 | rating = rating || 'good'; 227 | 228 | super({ 229 | id: 'cls', 230 | name: 'Cumulative Layout Shift', 231 | local, 232 | background, 233 | thresholds, 234 | rating 235 | }); 236 | } 237 | 238 | formatValue(value) { 239 | return clsFormatter.format(value); 240 | } 241 | 242 | } 243 | 244 | export class FCP extends Metric { 245 | 246 | constructor({local, background, rating}) { 247 | const thresholds = { 248 | good: FCPThresholds[0], 249 | poor: FCPThresholds[1] 250 | }; 251 | 252 | // TODO(rviscomi): Consider better defaults. 253 | local = local || 0; 254 | rating = rating || 'good'; 255 | 256 | super({ 257 | id: 'fcp', 258 | name: 'First Contentful Paint', 259 | local, 260 | background, 261 | thresholds, 262 | rating 263 | }); 264 | } 265 | 266 | formatValue(value) { 267 | value /= 1000; 268 | return secondsFormatter.format(value); 269 | } 270 | 271 | getInfo() { 272 | if (this.background) { 273 | return 'FCP inflated by tab loading in the background'; 274 | } 275 | 276 | return super.getInfo(); 277 | } 278 | 279 | } 280 | 281 | export class TTFB extends Metric { 282 | 283 | constructor({local, background, rating}) { 284 | const thresholds = { 285 | good: TTFBThresholds[0], 286 | poor: TTFBThresholds[1] 287 | }; 288 | 289 | // TODO(rviscomi): Consider better defaults. 290 | local = local || 0; 291 | rating = rating || 'good'; 292 | 293 | super({ 294 | id: 'ttfb', 295 | name: 'Time to First Byte', 296 | local, 297 | background, 298 | thresholds, 299 | rating 300 | }); 301 | } 302 | 303 | formatValue(value) { 304 | value /= 1000; 305 | return secondsFormatter.format(value); 306 | } 307 | 308 | } 309 | -------------------------------------------------------------------------------- /src/browser_action/on-each-interaction.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Google Inc. All Rights Reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | import {INPThresholds} from './web-vitals.js'; 15 | 16 | /** 17 | * @param {Function} callback 18 | */ 19 | export function onEachInteraction(callback) { 20 | const valueToRating = (score) => score <= INPThresholds[0] ? 'good' : score <= INPThresholds[1] ? 'needs-improvement' : 'poor'; 21 | 22 | const eventObserver = new PerformanceObserver((list) => { 23 | const entries = list.getEntries(); 24 | const interactions = {}; 25 | 26 | const getSelector = (node, maxLen) => { 27 | let sel = ''; 28 | 29 | try { 30 | while (node && node.nodeType !== 9) { 31 | const el = node; 32 | const part = el.id 33 | ? '#' + el.id 34 | : getName(el) + 35 | (el.classList && 36 | el.classList.value && 37 | el.classList.value.trim() && 38 | el.classList.value.trim().length 39 | ? '.' + el.classList.value.trim().replace(/\s+/g, '.') 40 | : ''); 41 | if (sel.length + part.length > (maxLen || 100) - 1) return sel || part; 42 | sel = sel ? part + '>' + sel : part; 43 | if (el.id) break; 44 | node = el.parentNode; 45 | } 46 | } catch (err) { 47 | // Do nothing... 48 | } 49 | return sel; 50 | }; 51 | 52 | // Filter all events to those with interactionids 53 | for (const entry of entries.filter((entry) => entry.interactionId)) { 54 | interactions[entry.interactionId] = interactions[entry.interactionId] || []; 55 | interactions[entry.interactionId].push(entry); 56 | } 57 | 58 | // Will report as a single interaction even if parts are in separate frames. 59 | // Consider splitting by animation frame. 60 | for (const interaction of Object.values(interactions)) { 61 | const entry = interaction.reduce((prev, curr) => prev.duration >= curr.duration ? prev : curr); 62 | const value = entry.duration; 63 | 64 | // Filter down LoAFs to ones that intersected any event startTime and any processingEnd 65 | const longAnimationFrameEntries = getIntersectingLoAFs(entry.startTime, entry.startTime + entry.value) 66 | 67 | const firstEntryWithTarget = interaction.find(entry => entry.target)?.target; 68 | 69 | callback({ 70 | attribution: { 71 | interactionTarget: getSelector(firstEntryWithTarget), 72 | interactionTargetElement: firstEntryWithTarget, 73 | interactionTime: entry.startTime, 74 | interactionType: entry.name.startsWith('key') ? 'keyboard' : 'pointer', 75 | longAnimationFrameEntries: longAnimationFrameEntries 76 | }, 77 | entries: interaction, 78 | name: 'Interaction', 79 | rating: valueToRating(value), 80 | value, 81 | }); 82 | } 83 | }); 84 | 85 | eventObserver.observe({ 86 | type: 'event', 87 | durationThreshold: 0, 88 | buffered: true, 89 | }); 90 | 91 | let recentLoAFs = []; 92 | 93 | const getIntersectingLoAFs = (start, end) => { 94 | const intersectingLoAFs = []; 95 | 96 | for (let i = 0, loaf; (loaf = recentLoAFs[i]); i++) { 97 | // If the LoAF ends before the given start time, ignore it. 98 | if (loaf.startTime + loaf.duration < start) continue; 99 | 100 | // If the LoAF starts after the given end time, ignore it and all 101 | // subsequent pending LoAFs (because they're in time order). 102 | if (loaf.startTime > end) break; 103 | 104 | // Still here? If so this LoAF intersects with the interaction. 105 | intersectingLoAFs.push(loaf); 106 | } 107 | return intersectingLoAFs; 108 | }; 109 | 110 | const loafObserver = new PerformanceObserver((list) => { 111 | // We report interactions immediately, so don't need to keep many LoAFs around. 112 | // Let's keep the last 5. 113 | recentLoAFs = recentLoAFs.concat(list.getEntries()).slice(-5); 114 | 115 | }); 116 | loafObserver.observe({ 117 | type: 'long-animation-frame', 118 | buffered: true, 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /src/browser_action/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

Metrics

9 | 10 | 13 |
14 | 15 |
16 | 46 |
47 | 48 |
49 | 50 |
51 | 52 | 53 | Desktop field data 54 | 55 | 56 | 57 | Phone field data 58 | 59 | 60 | 61 | 62 |
63 | 64 | 80 | 81 |
82 | 83 | 84 |

85 | As of January 2025, support for the Web Vitals extension has ended. 86 | We encourage all users to switch to the DevTools Performance panel instead. 87 | Learn more 88 |

89 | 90 |
91 | 92 | 93 |
94 |
95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /src/browser_action/popup.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Google Inc. All Rights Reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | import { loadLocalMetrics, getOptions, getURL } from './chrome.js'; 15 | import { CrUX } from './crux.js'; 16 | import { LCP, INP, CLS, FCP, TTFB } from './metric.js'; 17 | 18 | class Popup { 19 | 20 | constructor({metrics, background, options, url, error}) { 21 | if (error) { 22 | console.log(error); 23 | this.setStatus('Web Vitals are unavailable for this page.\n' + error); 24 | return; 25 | } 26 | 27 | const {timestamp, ..._metrics} = metrics; 28 | // Format as a short timestamp (HH:MM:SS). 29 | const formattedTimestamp = new Date(timestamp).toLocaleTimeString('en-US', {hourCycle: 'h23'}); 30 | 31 | this.timestamp = formattedTimestamp; 32 | this._metrics = _metrics; 33 | this.background = background; 34 | this.options = options; 35 | this.metrics = {}; 36 | this.url = url; 37 | 38 | this.init(); 39 | } 40 | 41 | init() { 42 | this.initStatus(); 43 | this.initPage(); 44 | this.initTimestamp(); 45 | this.initMetrics(); 46 | this.initFieldData(); 47 | this.showEOLNotice(); 48 | } 49 | 50 | initStatus() { 51 | this.setStatus('Loading field data…'); 52 | } 53 | 54 | initPage() { 55 | this.setPage(this.url); 56 | } 57 | 58 | initTimestamp() { 59 | const timestamp = document.getElementById('timestamp'); 60 | timestamp.innerText = this.timestamp; 61 | } 62 | 63 | initMetrics() { 64 | this.metrics.lcp = new LCP({ 65 | local: this._metrics.lcp.value, 66 | rating: this._metrics.lcp.rating, 67 | background: this.background 68 | }); 69 | this.metrics.cls = new CLS({ 70 | local: this._metrics.cls.value, 71 | rating: this._metrics.cls.rating, 72 | background: this.background 73 | }); 74 | this.metrics.inp = new INP({ 75 | local: this._metrics.inp.value, 76 | rating: this._metrics.inp.rating, 77 | background: this.background 78 | }); 79 | this.metrics.fcp = new FCP({ 80 | local: this._metrics.fcp.value, 81 | rating: this._metrics.fcp.rating, 82 | background: this.background 83 | }); 84 | this.metrics.ttfb = new TTFB({ 85 | local: this._metrics.ttfb.value, 86 | rating: this._metrics.ttfb.rating, 87 | background: this.background 88 | }); 89 | 90 | this.renderMetrics(); 91 | } 92 | 93 | initFieldData() { 94 | const formFactor = this.options.preferPhoneField ? CrUX.FormFactor.PHONE : CrUX.FormFactor.DESKTOP; 95 | CrUX.load(this.url, formFactor).then(fieldData => { 96 | console.log('CrUX data', fieldData); 97 | this.renderFieldData(fieldData, formFactor); 98 | }).catch(e => { 99 | console.warn('Unable to load any CrUX data. See https://developer.chrome.com/blog/web-vitals-extension', e); 100 | this.setStatus('Local metrics only (field data unavailable)'); 101 | }); 102 | } 103 | 104 | showEOLNotice() { 105 | chrome.storage.sync.get({hideEOLNotice: false}, ({hideEOLNotice}) => { 106 | if (hideEOLNotice) { 107 | return; 108 | } 109 | const notice = document.getElementById('eol-notice'); 110 | notice.showPopover(); 111 | const hideNoticeToggle = document.getElementById('hide-eol-notice'); 112 | hideNoticeToggle.addEventListener('change', (e) => { 113 | chrome.storage.sync.set({hideEOLNotice: e.target.checked}); 114 | }); 115 | }); 116 | } 117 | 118 | setStatus(status) { 119 | const statusElement = document.getElementById('status'); 120 | 121 | if (typeof status === 'string') { 122 | statusElement.innerText = status; 123 | } else { 124 | statusElement.replaceChildren(status); 125 | } 126 | } 127 | 128 | setPage(url) { 129 | const page = document.getElementById('page'); 130 | page.innerText = url; 131 | page.title = url; 132 | } 133 | 134 | setDevice(formFactor) { 135 | const deviceElement = document.querySelector('.device-icon'); 136 | deviceElement.classList.add(`device-${formFactor.toLowerCase()}`); 137 | } 138 | 139 | setHovercardText(metric, fieldData, formFactor='') { 140 | const hovercard = document.querySelector(`#${metric.id} .hovercard`); 141 | const abbr = metric.abbr; 142 | const local = metric.formatValue(metric.local); 143 | const assessment = metric.rating; 144 | let text = `Your local ${abbr} experience is ${local} and rated ${assessment}.`; 145 | 146 | if (fieldData) { 147 | const assessmentIndex = metric.getAssessmentIndex(metric.rating); 148 | const density = metric.getDensity(assessmentIndex, 0); 149 | const scope = CrUX.isOriginFallback(fieldData) ? 'origin' : 'page'; 150 | text += ` ${density} of real-user ${formFactor.toLowerCase()} ${abbr} experiences on this ${scope} were also rated ${assessment}.` 151 | } 152 | 153 | hovercard.innerHTML = text; 154 | } 155 | 156 | renderMetrics() { 157 | Object.values(this.metrics).forEach(this.renderMetric.bind(this)); 158 | } 159 | 160 | renderMetric(metric) { 161 | const template = document.getElementById('metric-template'); 162 | const fragment = template.content.cloneNode(true); 163 | const metricElement = fragment.querySelector('.metric-wrapper'); 164 | const name = fragment.querySelector('.metric-name'); 165 | const local = fragment.querySelector('.metric-performance-local'); 166 | const localValue = fragment.querySelector('.metric-performance-local-value'); 167 | const infoElement = fragment.querySelector('.info'); 168 | const info = metric.getInfo() || ''; 169 | const rating = metric.rating; 170 | 171 | metricElement.id = metric.id; 172 | name.innerText = metric.name; 173 | local.style.marginLeft = metric.getRelativePosition(metric.local); 174 | localValue.innerText = metric.formatValue(metric.local); 175 | metricElement.classList.toggle(rating, !!rating); 176 | infoElement.title = info; 177 | infoElement.classList.toggle('hidden', info == ''); 178 | 179 | template.parentElement.appendChild(fragment); 180 | 181 | requestAnimationFrame(_ => { 182 | // Check reversal before and after the transition is settled. 183 | this.checkReversal(metric); 184 | this.setHovercardText(metric); 185 | }); 186 | this.whenSettled(metric).then(_ => this.checkReversal(metric)); 187 | } 188 | 189 | checkReversal(metric) { 190 | const container = document.querySelector(`#${metric.id} .metric-performance`); 191 | const local = document.querySelector(`#${metric.id} .metric-performance-local`); 192 | const localValue = document.querySelector(`#${metric.id} .metric-performance-local-value`); 193 | 194 | const containerBoundingRect = container.getBoundingClientRect(); 195 | const localValueBoundingRect = localValue.getBoundingClientRect(); 196 | const isOverflow = localValueBoundingRect.right > containerBoundingRect.right; 197 | 198 | local.classList.toggle('reversed', isOverflow || local.classList.contains('reversed')); 199 | } 200 | 201 | renderFieldData(fieldData, formFactor) { 202 | if (CrUX.isOriginFallback(fieldData)) { 203 | const fragment = document.createDocumentFragment(); 204 | const span = document.createElement('span'); 205 | span.innerHTML = `Page-level field data is not available
Comparing local metrics to origin-level ${formFactor.toLowerCase()} field data instead`; 206 | fragment.appendChild(span); 207 | this.setStatus(fragment); 208 | this.setPage(CrUX.getOrigin(fieldData)); 209 | } else { 210 | this.setStatus(`Local metrics compared to ${formFactor.toLowerCase()} field data`); 211 | 212 | const normalizedUrl = CrUX.getNormalizedUrl(fieldData); 213 | if (normalizedUrl) { 214 | this.setPage(normalizedUrl); 215 | } 216 | } 217 | 218 | const metrics = CrUX.getMetrics(fieldData).forEach(({id, data}) => { 219 | const metric = this.metrics[id]; 220 | if (!metric) { 221 | // The API may return additional metrics that we don't support. 222 | return; 223 | } 224 | 225 | metric.distribution = CrUX.getDistribution(data); 226 | 227 | const local = document.querySelector(`#${metric.id} .metric-performance-local`); 228 | local.style.marginLeft = metric.getRelativePosition(metric.local); 229 | 230 | ['good', 'needs-improvement', 'poor'].forEach((rating, i) => { 231 | const ratingElement = document.querySelector(`#${metric.id} .metric-performance-distribution-rating.${rating}`); 232 | 233 | ratingElement.innerText = metric.getDensity(i); 234 | ratingElement.style.setProperty('--rating-width', metric.getDensity(i, 2)); 235 | ratingElement.style.setProperty('--min-rating-width', `${metric.MIN_PCT * 100}%`); 236 | }); 237 | 238 | this.setDevice(formFactor); 239 | this.setHovercardText(metric, fieldData, formFactor); 240 | this.whenSettled(metric).then(_ => this.checkReversal(metric)); 241 | }); 242 | } 243 | 244 | whenSettled(metric) { 245 | const local = document.querySelector(`#${metric.id} .metric-performance-local`); 246 | return new Promise(resolve => { 247 | local.addEventListener('transitionend', resolve); 248 | }); 249 | } 250 | 251 | } 252 | 253 | Promise.all([loadLocalMetrics(), getOptions(), getURL()]).then(([localMetrics, options, url]) => { 254 | window.popup = new Popup({...localMetrics, options, url}); 255 | }); 256 | -------------------------------------------------------------------------------- /src/browser_action/vitals.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Google Inc. All Rights Reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | (async () => { 15 | const src = chrome.runtime.getURL('src/browser_action/web-vitals.js'); 16 | const webVitals = await import(src); 17 | const { onEachInteraction } = await import(chrome.runtime.getURL('src/browser_action/on-each-interaction.js')); 18 | let overlayClosedForSession = false; 19 | let latestCLS = {}; 20 | let enableLogging = localStorage.getItem('web-vitals-extension-debug')=='TRUE'; 21 | let enableUserTiming = localStorage.getItem('web-vitals-extension-user-timing')=='TRUE'; 22 | let tabLoadedInBackground; 23 | 24 | const COLOR_GOOD = '#0CCE6A'; 25 | const COLOR_NEEDS_IMPROVEMENT = '#FFA400'; 26 | const COLOR_POOR = '#FF4E42'; 27 | const RATING_COLORS = { 28 | 'good': COLOR_GOOD, 29 | 'needs-improvement': COLOR_NEEDS_IMPROVEMENT, 30 | 'poor': COLOR_POOR 31 | }; 32 | 33 | // CLS update frequency 34 | const DEBOUNCE_DELAY = 500; 35 | 36 | // Identifiable prefix for console logging 37 | const LOG_PREFIX = '[Web Vitals Extension]'; 38 | 39 | // Registry for badge metrics 40 | const badgeMetrics = initializeMetrics(); 41 | 42 | // Set up extension message port with the service worker 43 | let port = chrome.runtime.connect(); 44 | 45 | // Re-establish the port connection on bfcache restore 46 | window.addEventListener('pageshow', (event) => { 47 | if (event.persisted) { 48 | // The page is restored from BFCache, set up a new connection. 49 | port = chrome.runtime.connect(); 50 | } 51 | }); 52 | 53 | const secondsFormatter = new Intl.NumberFormat(undefined, { 54 | unit: "second", 55 | style: 'unit', 56 | unitDisplay: "short", 57 | minimumFractionDigits: 3, 58 | maximumFractionDigits: 3 59 | }); 60 | 61 | const millisecondsFormatter = new Intl.NumberFormat(undefined, { 62 | unit: "millisecond", 63 | style: 'unit', 64 | unitDisplay: "short", 65 | minimumFractionDigits: 0, 66 | maximumFractionDigits: 0 67 | }); 68 | 69 | const clsFormatter = new Intl.NumberFormat(undefined, { 70 | unitDisplay: "short", 71 | minimumFractionDigits: 2, 72 | maximumFractionDigits: 2 73 | }); 74 | 75 | function initializeMetrics() { 76 | let metricsState = localStorage.getItem('web-vitals-extension-metrics'); 77 | if (metricsState) { 78 | metricsState = JSON.parse(metricsState); 79 | 80 | if (metricsState.navigationStart == performance.timing.navigationStart) { 81 | return metricsState; 82 | } 83 | } 84 | 85 | // Create a fresh state. 86 | // Default all metric values to null. 87 | return { 88 | lcp: { 89 | value: null, 90 | rating: null, 91 | }, 92 | cls: { 93 | value: null, 94 | rating: null, 95 | }, 96 | inp: { 97 | value: null, 98 | rating: null, 99 | }, 100 | fcp: { 101 | value: null, 102 | rating: null, 103 | }, 104 | ttfb: { 105 | value: null, 106 | rating: null, 107 | }, 108 | // This is used to distinguish between navigations. 109 | // TODO: Is there a cleaner way? 110 | navigationStart: performance.timing.navigationStart 111 | }; 112 | 113 | } 114 | 115 | /** 116 | * Very simple classifier for metrics values 117 | * @param {Object} metrics 118 | * @return {String} overall metrics score 119 | */ 120 | function scoreBadgeMetrics(metrics) { 121 | // Note: overallScore is treated as a string rather than 122 | // a boolean to give us the flexibility of introducing a 123 | // 'NEEDS IMPROVEMENT' option here in the future. 124 | const overallScore = ( 125 | metrics.lcp.rating === 'good' && 126 | (metrics.cls.rating === 'good' || metrics.cls.rating === null) && 127 | (metrics.inp.rating === 'good' || metrics.inp.rating === null) 128 | ) ? 'GOOD' : 'POOR'; 129 | return overallScore; 130 | } 131 | 132 | /** 133 | * 134 | * Draw or update the HUD overlay to the page 135 | * @param {Object} metrics 136 | * @param {Number} tabId 137 | */ 138 | function drawOverlay(metrics) { 139 | 140 | localStorage.setItem('web-vitals-extension-metrics', JSON.stringify(metrics)); 141 | 142 | // Check for preferences set in options 143 | chrome.storage.sync.get({ 144 | enableOverlay: false, 145 | debug: false, 146 | userTiming: false 147 | }, ({ 148 | enableOverlay, debug, userTiming 149 | }) => { 150 | if (enableOverlay === true && overlayClosedForSession == false) { 151 | let overlayElement = document.getElementById('web-vitals-extension-overlay'); 152 | if (overlayElement === null) { 153 | // Overlay 154 | overlayElement = document.createElement('div'); 155 | overlayElement.id = 'web-vitals-extension-overlay'; 156 | overlayElement.classList.add('web-vitals-chrome-extension'); 157 | document.body.appendChild(overlayElement); 158 | 159 | // Overlay close button 160 | overlayClose = document.createElement('button'); 161 | overlayClose.innerText = 'Close'; 162 | overlayClose.id = 'web-vitals-close'; 163 | overlayClose.className = 'lh-overlay-close'; 164 | overlayClose.addEventListener('click', () => { 165 | overlayElement.remove(); 166 | overlayClose.remove(); 167 | overlayClosedForSession = true; 168 | }); 169 | 170 | document.body.appendChild(overlayClose); 171 | } 172 | 173 | overlayElement.innerHTML = buildOverlayTemplate(metrics); 174 | } 175 | 176 | if (debug) { 177 | localStorage.setItem('web-vitals-extension-debug', 'TRUE'); 178 | enableLogging = true; 179 | } else { 180 | localStorage.removeItem('web-vitals-extension-debug'); 181 | enableLogging = false; 182 | } 183 | if (userTiming) { 184 | localStorage.setItem('web-vitals-extension-user-timing', 'TRUE'); 185 | enableUserTiming = true; 186 | } else { 187 | localStorage.removeItem('web-vitals-extension-user-timing'); 188 | enableUserTiming = false; 189 | } 190 | }); 191 | } 192 | 193 | /** 194 | * 195 | * Broadcasts metrics updates using postMessage, triggering 196 | * updates to the badge, overlay and logs as appropriate 197 | * @param {Object} metric 198 | */ 199 | function broadcastMetricsUpdates(metric) { 200 | if (badgeMetrics === undefined) { 201 | return; 202 | } 203 | if (enableUserTiming) { 204 | addUserTimings(metric); 205 | } 206 | badgeMetrics[metric.name.toLowerCase()].value = metric.value; 207 | badgeMetrics[metric.name.toLowerCase()].rating = metric.rating; 208 | badgeMetrics.timestamp = new Date().toISOString(); 209 | const passes = scoreBadgeMetrics(badgeMetrics); 210 | 211 | // Broadcast metrics updates for badging 212 | try { 213 | port.postMessage({ 214 | passesAllThresholds: passes, 215 | metrics: badgeMetrics, 216 | }); 217 | } catch (_) { 218 | // Do nothing on error, which can happen on tab switches 219 | } 220 | 221 | drawOverlay(badgeMetrics); 222 | 223 | if (enableLogging) { 224 | logSummaryInfo(metric); 225 | } 226 | } 227 | 228 | // Listed to the message response containing the tab id 229 | // to set the tabLoadedInBackground. 230 | port.onMessage.addListener((response) => { 231 | if (response.tabId === undefined) { 232 | return; 233 | } 234 | 235 | // Only set the tabLoadedInBackground if not already set 236 | if (tabLoadedInBackground === undefined) { 237 | const key = response.tabId.toString(); 238 | chrome.storage.local.get(key, result => { 239 | tabLoadedInBackground = result[key]; 240 | }); 241 | } 242 | }); 243 | 244 | async function logSummaryInfo(metric) { 245 | let formattedValue; 246 | switch(metric.name) { 247 | case 'CLS': 248 | formattedValue = clsFormatter.format(metric.value); 249 | break; 250 | case 'INP': 251 | case 'Interaction': 252 | formattedValue = millisecondsFormatter.format(metric.value); 253 | break; 254 | default: 255 | formattedValue = secondsFormatter.format(metric.value / 1000); 256 | } 257 | 258 | // Log the EOL warning at the same time as TTFB, which should only occur once per page load. 259 | if (metric.name === 'TTFB') { 260 | console.warn(`${LOG_PREFIX} As of January 2025, support for the Web Vitals extension has ended. We encourage all users to switch to the DevTools Performance panel instead. Learn more: https://developer.chrome.com/blog/web-vitals-extension`); 261 | } 262 | 263 | console.groupCollapsed( 264 | `${LOG_PREFIX} ${metric.name} %c${formattedValue} (${metric.rating})`, 265 | `color: ${RATING_COLORS[metric.rating] || 'inherit'}` 266 | ); 267 | 268 | if (metric.name == 'LCP' && 269 | metric.attribution && 270 | metric.attribution.lcpEntry && 271 | metric.attribution.navigationEntry) { 272 | if (tabLoadedInBackground) { 273 | console.warn('LCP inflated by tab loading in the background'); 274 | } 275 | console.log('LCP element:', metric.attribution.lcpEntry.element); 276 | console.table([{ 277 | 'LCP sub-part': 'Time to first byte', 278 | 'Time (ms)': Math.round(metric.attribution.timeToFirstByte, 0), 279 | }, { 280 | 'LCP sub-part': 'Resource load delay', 281 | 'Time (ms)': Math.round(metric.attribution.resourceLoadDelay, 0), 282 | }, { 283 | 'LCP sub-part': 'Resource load duration', 284 | 'Time (ms)': Math.round(metric.attribution.resourceLoadDuration, 0), 285 | }, { 286 | 'LCP sub-part': 'Element render delay', 287 | 'Time (ms)': Math.round(metric.attribution.elementRenderDelay, 0), 288 | }]); 289 | } 290 | 291 | else if (metric.name == 'FCP' && 292 | metric.attribution && 293 | metric.attribution.fcpEntry && 294 | metric.attribution.navigationEntry) { 295 | if (tabLoadedInBackground) { 296 | console.warn('FCP inflated by tab loading in the background'); 297 | } 298 | console.log('FCP loadState:', metric.attribution.loadState); 299 | console.table([{ 300 | 'FCP sub-part': 'Time to first byte', 301 | 'Time (ms)': Math.round(metric.attribution.timeToFirstByte, 0), 302 | }, { 303 | 'FCP sub-part': 'FCP render delay', 304 | 'Time (ms)': Math.round(metric.attribution.firstByteToFCP, 0), 305 | }]); 306 | } 307 | 308 | else if (metric.name == 'CLS' && metric.entries.length) { 309 | for (const entry of metric.entries) { 310 | console.log('Layout shift - score: ', Math.round(entry.value * 10000) / 10000); 311 | for (const source of entry.sources) { 312 | console.log(source.node); 313 | } 314 | }; 315 | } 316 | 317 | else if ((metric.name == 'INP'|| metric.name == 'Interaction') && metric.attribution) { 318 | const eventTarget = metric.attribution.interactionTargetElement; 319 | console.log('Interaction target:', eventTarget || metric.attribution.interactionTarget); 320 | console.log(`Interaction event type: %c${metric.attribution.interactionType}`, 'font-family: monospace'); 321 | 322 | // Sub parts are only available for INP events and not Interactions 323 | if (metric.name == 'INP') { 324 | console.table([{ 325 | 'Interaction sub-part': 'Input delay', 326 | 'Time (ms)': Math.round(metric.attribution.inputDelay, 0), 327 | }, 328 | { 329 | 'Interaction sub-part': 'Processing duration', 330 | 'Time (ms)': Math.round(metric.attribution.processingDuration, 0), 331 | }, 332 | { 333 | 'Interaction sub-part': 'Presentation delay', 334 | 'Time (ms)': Math.round(metric.attribution.presentationDelay, 0), 335 | }]); 336 | } 337 | 338 | if (metric.attribution.longAnimationFrameEntries) { 339 | 340 | const allScripts = metric.attribution.longAnimationFrameEntries.map(a => a.scripts).flat(); 341 | 342 | if (allScripts.length > 0) { 343 | 344 | const sortedScripts = allScripts.sort((a,b) => b.duration - a.duration); 345 | 346 | // Pull out the pieces of interest for console table 347 | scriptData = sortedScripts.map((a) => ( 348 | { 349 | 'Duration': Math.round(a.duration, 0), 350 | 'Type': a.invokerType || null, 351 | 'Invoker': a.invoker || null, 352 | 'Function': a.sourceFunctionName || null, 353 | 'Source (links below)': a.sourceURL || null, 354 | 'Char position': a.sourceCharPosition || null 355 | } 356 | )); 357 | console.log("Long Animation Frame scripts:"); 358 | console.table(scriptData); 359 | 360 | // Get a list of scripts by sourceURL so we can log to console for 361 | // easy linked lookup. We won't include sourceCharPosition as 362 | // Devtools doesn't support linking to a character position and only 363 | // line numbers. 364 | const scriptsBySource = sortedScripts.reduce((acc, {sourceURL, duration}) => { 365 | if (sourceURL) { // Exclude empty URLs 366 | (acc[sourceURL] = acc[sourceURL] || []).push(duration); 367 | } 368 | return acc; 369 | }, {}); 370 | 371 | for (const [key, value] of Object.entries(scriptsBySource)) { 372 | console.log(`Script source link: ${key} (Duration${value.length > 1 ? 's' : ''}: ${value})`); 373 | } 374 | 375 | } 376 | } 377 | } 378 | 379 | else if (metric.name == 'TTFB' && 380 | metric.attribution && 381 | metric.attribution.navigationEntry) { 382 | console.log('TTFB navigation type:', metric.navigationType); 383 | console.table([{ 384 | 'TTFB sub-part': 'Waiting duration', 385 | 'Time (ms)': Math.round(metric.attribution.waitingDuration, 0), 386 | }, { 387 | 'TTFB sub-part': 'Cache duration', 388 | 'Time (ms)': Math.round(metric.attribution.cacheDuration, 0), 389 | }, { 390 | 'TTFB sub-part': 'DNS duration', 391 | 'Time (ms)': Math.round(metric.attribution.dnsDuration, 0), 392 | }, { 393 | 'TTFB sub-part': 'Connection duration', 394 | 'Time (ms)': Math.round(metric.attribution.connectionDuration, 0), 395 | }, { 396 | 'TTFB sub-part': 'Request duration', 397 | 'Time (ms)': Math.round(metric.attribution.requestDuration, 0), 398 | }]); 399 | } 400 | 401 | console.log(metric); 402 | console.groupEnd(); 403 | } 404 | 405 | function addUserTimings(metric) { 406 | switch (metric.name) { 407 | case "LCP": 408 | if (!(metric.attribution && metric.attribution.lcpEntry && metric.attribution.navigationEntry)) { 409 | break; 410 | } 411 | 412 | const navEntry = metric.attribution.navigationEntry; 413 | // Set the start time to the later of the actual start time or the activationStart (for prerender) or 0 414 | const startTime = Math.max(navEntry.startTime, navEntry.activationStart) || 0; 415 | // Add the performance marks for the Performance Panel 416 | performance.measure(`${LOG_PREFIX} LCP.timeToFirstByte`, { 417 | start: startTime, 418 | duration: metric.attribution.timeToFirstByte, 419 | }); 420 | performance.measure(`${LOG_PREFIX} LCP.resourceLoadDelay`, { 421 | start: startTime + metric.attribution.timeToFirstByte, 422 | duration: metric.attribution.resourceLoadDelay, 423 | }); 424 | performance.measure(`${LOG_PREFIX} LCP.resourceLoadDuration`, { 425 | start: 426 | startTime + 427 | metric.attribution.timeToFirstByte + 428 | metric.attribution.resourceLoadDelay, 429 | duration: metric.attribution.resourceLoadDuration, 430 | }); 431 | performance.measure(`${LOG_PREFIX} LCP.elementRenderDelay`, { 432 | duration: metric.attribution.elementRenderDelay, 433 | end: metric.value 434 | }); 435 | break; 436 | 437 | case "INP": 438 | if (!(metric.attribution)) { 439 | break; 440 | } 441 | 442 | const attribution = metric.attribution; 443 | const interactionTime = attribution.interactionTime; 444 | const inputDelay = attribution.inputDelay; 445 | const processingDuration = attribution.processingDuration; 446 | const presentationDelay = attribution.presentationDelay; 447 | 448 | performance.measure(`${LOG_PREFIX} INP.inputDelay (${metric.attribution.interactionType})`, { 449 | start: interactionTime, 450 | end: interactionTime + inputDelay, 451 | }); 452 | performance.measure(`${LOG_PREFIX} INP.processingTime (${metric.attribution.interactionType})`, { 453 | start: interactionTime + inputDelay, 454 | end: interactionTime + inputDelay + processingDuration, 455 | }); 456 | performance.measure(`${LOG_PREFIX} INP.presentationDelay (${metric.attribution.interactionType})`, { 457 | start: interactionTime + inputDelay + processingDuration, 458 | end: interactionTime + inputDelay + processingDuration + presentationDelay, 459 | }); 460 | break; 461 | } 462 | } 463 | 464 | /** 465 | * Broadcasts the latest CLS value 466 | */ 467 | function broadcastCLS() { 468 | broadcastMetricsUpdates(latestCLS); 469 | } 470 | 471 | /** 472 | * Debounces the broadcast of CLS values for stability. 473 | * broadcastCLS is invoked on the trailing edge of the 474 | * DEBOUNCE_DELAY timeout if invoked more than once during 475 | * the wait timeout. 476 | */ 477 | let debouncedCLSBroadcast = () => {}; 478 | if (typeof _ !== 'undefined') { 479 | debouncedCLSBroadcast = _.debounce(broadcastCLS, DEBOUNCE_DELAY, { 480 | leading: true, 481 | trailing: true, 482 | maxWait: 1000}); 483 | } 484 | /** 485 | * 486 | * Fetches Web Vitals metrics via WebVitals.js 487 | */ 488 | function fetchWebPerfMetrics() { 489 | // web-vitals.js doesn't have a way to remove previous listeners, so we'll save whether 490 | // we've already installed the listeners before installing them again. 491 | // See https://github.com/GoogleChrome/web-vitals/issues/55. 492 | if (self._hasInstalledPerfMetrics) return; 493 | self._hasInstalledPerfMetrics = true; 494 | 495 | webVitals.onCLS((metric) => { 496 | // As CLS values can fire frequently in the case 497 | // of animations or highly-dynamic content, we 498 | // debounce the broadcast of the metric. 499 | latestCLS = metric; 500 | debouncedCLSBroadcast(); 501 | }, { reportAllChanges: true }); 502 | 503 | webVitals.onLCP(broadcastMetricsUpdates, { reportAllChanges: true }); 504 | webVitals.onINP((metric) => { 505 | broadcastMetricsUpdates(metric) 506 | }, { reportAllChanges: true }); 507 | webVitals.onFCP(broadcastMetricsUpdates, { reportAllChanges: true }); 508 | webVitals.onTTFB(broadcastMetricsUpdates, { reportAllChanges: true }); 509 | 510 | if (enableLogging) { 511 | onEachInteraction((metric) => { 512 | logSummaryInfo(metric, false); 513 | }); 514 | } 515 | } 516 | 517 | /** 518 | * Build a template of metrics 519 | * @param {Object} metrics The metrics 520 | * @return {String} a populated template of metrics 521 | */ 522 | function buildOverlayTemplate(metrics) { 523 | return ` 524 |
525 |
526 |
527 |
528 | Metrics 529 |
530 |
531 |
532 |
533 |
534 |
535 | Largest Contentful Paint 536 | ${tabLoadedInBackground ? 'Value inflated as tab was loaded in background' : ''} 537 |
538 |
${secondsFormatter.format((metrics.lcp.value || 0)/1000)}
539 |
540 |
541 |
542 |
543 | Cumulative Layout Shift 544 |
${clsFormatter.format( metrics.cls.value || 0)}
545 |
546 |
547 |
548 |
549 | 550 | Interaction to Next Paint 551 | ${metrics.inp.value === null ? '(waiting for input)' : ''} 552 | 553 |
${ 554 | metrics.inp.value === null ? '' : `${millisecondsFormatter.format(metrics.inp.value)}` 555 | }
556 |
557 |
558 |
559 |
560 |
561 | First Contentful Paint 562 | ${tabLoadedInBackground ? 'Value inflated as tab was loaded in background' : ''} 563 |
564 |
${secondsFormatter.format((metrics.fcp.value || 0)/1000)}
565 |
566 |
567 |
568 |
569 |
570 | 571 | Time to First Byte 572 | 573 |
${secondsFormatter.format((metrics.ttfb.value || 0)/1000)}
574 |
575 |
576 |
577 |
578 |
579 |
580 |
`; 581 | } 582 | 583 | fetchWebPerfMetrics(); 584 | })(); 585 | -------------------------------------------------------------------------------- /src/browser_action/web-vitals.js: -------------------------------------------------------------------------------- 1 | var t,e,n=function(){var t=self.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0];if(t&&t.responseStart>0&&t.responseStart(e||100)-1)return n||a;if(n=n?a+">"+n:a,r.id)break;t=r.parentNode}}catch(t){}return n},o=-1,c=function(){return o},u=function(t){addEventListener("pageshow",(function(e){e.persisted&&(o=e.timeStamp,t(e))}),!0)},s=function(){var t=n();return t&&t.activationStart||0},f=function(t,e){var r=n(),i="navigate";c()>=0?i="back-forward-cache":r&&(document.prerendering||s()>0?i="prerender":document.wasDiscarded?i="restore":r.type&&(i=r.type.replace(/_/g,"-")));return{name:t,value:void 0===e?-1:e,rating:"good",delta:0,entries:[],id:"v4-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:i}},d=function(t,e,n){try{if(PerformanceObserver.supportedEntryTypes.includes(t)){var r=new PerformanceObserver((function(t){Promise.resolve().then((function(){e(t.getEntries())}))}));return r.observe(Object.assign({type:t,buffered:!0},n||{})),r}}catch(t){}},l=function(t,e,n,r){var i,a;return function(o){e.value>=0&&(o||r)&&((a=e.value-(i||0))||void 0===i)&&(i=e.value,e.delta=a,e.rating=function(t,e){return t>e[1]?"poor":t>e[0]?"needs-improvement":"good"}(e.value,n),t(e))}},m=function(t){requestAnimationFrame((function(){return requestAnimationFrame((function(){return t()}))}))},p=function(t){document.addEventListener("visibilitychange",(function(){"hidden"===document.visibilityState&&t()}))},v=function(t){var e=!1;return function(){e||(t(),e=!0)}},g=-1,h=function(){return"hidden"!==document.visibilityState||document.prerendering?1/0:0},T=function(t){"hidden"===document.visibilityState&&g>-1&&(g="visibilitychange"===t.type?t.timeStamp:0,E())},y=function(){addEventListener("visibilitychange",T,!0),addEventListener("prerenderingchange",T,!0)},E=function(){removeEventListener("visibilitychange",T,!0),removeEventListener("prerenderingchange",T,!0)},S=function(){return g<0&&(g=h(),y(),u((function(){setTimeout((function(){g=h(),y()}),0)}))),{get firstHiddenTime(){return g}}},b=function(t){document.prerendering?addEventListener("prerenderingchange",(function(){return t()}),!0):t()},L=[1800,3e3],C=function(t,e){e=e||{},b((function(){var n,r=S(),i=f("FCP"),a=d("paint",(function(t){t.forEach((function(t){"first-contentful-paint"===t.name&&(a.disconnect(),t.startTimer.value&&(r.value=i,r.entries=a,n())},c=d("layout-shift",o);c&&(n=l(t,r,M,e.reportAllChanges),p((function(){o(c.takeRecords()),n(!0)})),u((function(){i=0,r=f("CLS",0),n=l(t,r,M,e.reportAllChanges),m((function(){return n()}))})),setTimeout(n,0))})))}((function(e){var n=function(t){var e,n={};if(t.entries.length){var i=t.entries.reduce((function(t,e){return t&&t.value>e.value?t:e}));if(i&&i.sources&&i.sources.length){var o=(e=i.sources).find((function(t){return t.node&&1===t.node.nodeType}))||e[0];o&&(n={largestShiftTarget:a(o.node),largestShiftTime:i.startTime,largestShiftValue:i.value,largestShiftSource:o,largestShiftEntry:i,loadState:r(i.startTime)})}}return Object.assign(t,{attribution:n})}(e);t(n)}),e)},w=function(t,e){C((function(e){var i=function(t){var e={timeToFirstByte:0,firstByteToFCP:t.value,loadState:r(c())};if(t.entries.length){var i=n(),a=t.entries[t.entries.length-1];if(i){var o=i.activationStart||0,u=Math.max(0,i.responseStart-o);e={timeToFirstByte:u,firstByteToFCP:t.value-u,loadState:r(t.entries[0].startTime),navigationEntry:i,fcpEntry:a}}}return Object.assign(t,{attribution:e})}(e);t(i)}),e)},x=0,I=1/0,k=0,A=function(t){t.forEach((function(t){t.interactionId&&(I=Math.min(I,t.interactionId),k=Math.max(k,t.interactionId),x=k?(k-I)/7+1:0)}))},F=function(){return t?x:performance.interactionCount||0},P=function(){"interactionCount"in performance||t||(t=d("event",A,{type:"event",buffered:!0,durationThreshold:0}))},B=[],O=new Map,R=0,j=function(){var t=Math.min(B.length-1,Math.floor((F()-R)/50));return B[t]},q=[],H=function(t){if(q.forEach((function(e){return e(t)})),t.interactionId||"first-input"===t.entryType){var e=B[B.length-1],n=O.get(t.interactionId);if(n||B.length<10||t.duration>e.latency){if(n)t.duration>n.latency?(n.entries=[t],n.latency=t.duration):t.duration===n.latency&&t.startTime===n.entries[0].startTime&&n.entries.push(t);else{var r={id:t.interactionId,latency:t.duration,entries:[t]};O.set(r.id,r),B.push(r)}B.sort((function(t,e){return e.latency-t.latency})),B.length>10&&B.splice(10).forEach((function(t){return O.delete(t.id)}))}}},N=function(t){var e=self.requestIdleCallback||self.setTimeout,n=-1;return t=v(t),"hidden"===document.visibilityState?t():(n=e(t),p(t)),n},W=[200,500],z=function(t,e){"PerformanceEventTiming"in self&&"interactionId"in PerformanceEventTiming.prototype&&(e=e||{},b((function(){var n;P();var r,i=f("INP"),a=function(t){N((function(){t.forEach(H);var e=j();e&&e.latency!==i.value&&(i.value=e.latency,i.entries=e.entries,r())}))},o=d("event",a,{durationThreshold:null!==(n=e.durationThreshold)&&void 0!==n?n:40});r=l(t,i,W,e.reportAllChanges),o&&(o.observe({type:"first-input",buffered:!0}),p((function(){a(o.takeRecords()),r(!0)})),u((function(){R=F(),B.length=0,O.clear(),i=f("INP"),r=l(t,i,W,e.reportAllChanges)})))})))},U=[],V=[],_=0,G=new WeakMap,J=new Map,K=-1,Q=function(t){U=U.concat(t),X()},X=function(){K<0&&(K=N(Y))},Y=function(){J.size>10&&J.forEach((function(t,e){O.has(e)||J.delete(e)}));var t=B.map((function(t){return G.get(t.entries[0])})),e=V.length-50;V=V.filter((function(n,r){return r>=e||t.includes(n)}));for(var n=new Set,r=0;r_&&e>a||n.has(t)})),K=-1};q.push((function(t){t.interactionId&&t.target&&!J.has(t.interactionId)&&J.set(t.interactionId,t.target)}),(function(t){var e,n=t.startTime+t.duration;_=Math.max(_,t.processingEnd);for(var r=V.length-1;r>=0;r--){var i=V[r];if(Math.abs(n-i.renderTime)<=8){(e=i).startTime=Math.min(t.startTime,e.startTime),e.processingStart=Math.min(t.processingStart,e.processingStart),e.processingEnd=Math.max(t.processingEnd,e.processingEnd),e.entries.push(t);break}}e||(e={startTime:t.startTime,processingStart:t.processingStart,processingEnd:t.processingEnd,renderTime:n,entries:[t]},V.push(e)),(t.interactionId||"first-input"===t.entryType)&&G.set(t,e),X()}));var Z,$,tt,et,nt=function(t,e){for(var n,r=[],i=0;n=U[i];i++)if(!(n.startTime+n.duratione)break;r.push(n)}return r},rt=function(t,n){e||(e=d("long-animation-frame",Q)),z((function(e){var n=function(t){var e=t.entries[0],n=G.get(e),i=e.processingStart,o=n.processingEnd,c=n.entries.sort((function(t,e){return t.processingStart-e.processingStart})),u=nt(e.startTime,o),s=t.entries.find((function(t){return t.target})),f=s&&s.target||J.get(e.interactionId),d=[e.startTime+e.duration,o].concat(u.map((function(t){return t.startTime+t.duration}))),l=Math.max.apply(Math,d),m={interactionTarget:a(f),interactionTargetElement:f,interactionType:e.name.startsWith("key")?"keyboard":"pointer",interactionTime:e.startTime,nextPaintTime:l,processedEventEntries:c,longAnimationFrameEntries:u,inputDelay:i-e.startTime,processingDuration:o-i,presentationDelay:Math.max(l-o,0),loadState:r(e.startTime)};return Object.assign(t,{attribution:m})}(e);t(n)}),n)},it=[2500,4e3],at={},ot=function(t,e){!function(t,e){e=e||{},b((function(){var n,r=S(),i=f("LCP"),a=function(t){e.reportAllChanges||(t=t.slice(-1)),t.forEach((function(t){t.startTime=0&&$1e12?new Date:performance.now())-t.timeStamp;"pointerdown"==t.type?function(t,e){var n=function(){mt(t,e),i()},r=function(){i()},i=function(){removeEventListener("pointerup",n,dt),removeEventListener("pointercancel",r,dt)};addEventListener("pointerup",n,dt),addEventListener("pointercancel",r,dt)}(e,t):mt(e,t)}},gt=function(t){["mousedown","keydown","touchstart","pointerdown"].forEach((function(e){return t(e,vt,dt)}))},ht=[100,300],Tt=function(t,e){e=e||{},b((function(){var n,r=S(),i=f("FID"),a=function(t){t.startTime { 15 | const readyStateCheckInterval = setInterval(() => { 16 | if (document.readyState === 'complete') { 17 | clearInterval(readyStateCheckInterval); 18 | 19 | // ---------------------------------------------------------- 20 | // This part of the script triggers when page is done loading 21 | // ---------------------------------------------------------- 22 | } 23 | }, 10); 24 | }); 25 | -------------------------------------------------------------------------------- /src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Core Web Vitals Options 6 | 7 | 8 | 9 | 10 | 11 |
12 |
Options
13 |
14 | 15 | 16 | 20 |
21 | 25 |
26 | 30 |
31 | 35 |
36 | 40 |
41 | (Learn more) 45 |
46 | 47 |
48 | 49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/options/options.js: -------------------------------------------------------------------------------- 1 | const optionsOverlayNode = document.getElementById('overlay'); 2 | const optionsConsoleLoggingNode = document.getElementById('consoleLogging'); 3 | const optionsNoBadgeAnimation = document.getElementById('noBadgeAnimation'); 4 | const optionsUserTimingNode = document.getElementById('userTiming'); 5 | const optionsPreferPhoneFieldNode = document.getElementById('preferPhoneField'); 6 | const optionsHideEOLNotice = document.getElementById('hideEOLNotice'); 7 | const optionsSaveBtn = document.getElementById('save'); 8 | const optionsStatus = document.getElementById('status'); 9 | 10 | /** 11 | * Save options to Chrome storage 12 | */ 13 | function saveOptions() { 14 | chrome.storage.sync.set({ 15 | enableOverlay: optionsOverlayNode.checked, 16 | debug: optionsConsoleLoggingNode.checked, 17 | userTiming: optionsUserTimingNode.checked, 18 | preferPhoneField: optionsPreferPhoneFieldNode.checked, 19 | noBadgeAnimation: optionsNoBadgeAnimation.checked, 20 | hideEOLNotice: optionsHideEOLNotice.checked, 21 | }, () => { 22 | // Update status to let user know options were saved. 23 | optionsStatus.textContent = 'Options saved.'; 24 | setTimeout(() => { 25 | optionsStatus.textContent = ''; 26 | }, 750); 27 | }); 28 | } 29 | 30 | /** 31 | * Restores select box and checkbox state using the 32 | * preferences stored in chrome.storage 33 | */ 34 | function restoreOptions() { 35 | chrome.storage.sync.get({ 36 | enableOverlay: false, 37 | debug: false, 38 | userTiming: false, 39 | preferPhoneField: false, 40 | noBadgeAnimation: false, 41 | hideEOLNotice: false, 42 | }, ({enableOverlay, debug, userTiming, preferPhoneField, noBadgeAnimation, hideEOLNotice}) => { 43 | optionsOverlayNode.checked = enableOverlay; 44 | optionsConsoleLoggingNode.checked = debug; 45 | optionsUserTimingNode.checked = userTiming; 46 | optionsPreferPhoneFieldNode.checked = preferPhoneField; 47 | optionsNoBadgeAnimation.checked = noBadgeAnimation; 48 | optionsHideEOLNotice.checked = hideEOLNotice; 49 | }); 50 | } 51 | document.addEventListener('DOMContentLoaded', restoreOptions); 52 | optionsSaveBtn.addEventListener('click', saveOptions); 53 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.13": 6 | version "7.22.13" 7 | resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz" 8 | integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w== 9 | dependencies: 10 | "@babel/highlight" "^7.22.13" 11 | chalk "^2.4.2" 12 | 13 | "@babel/generator@^7.23.0": 14 | version "7.23.0" 15 | resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz" 16 | integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g== 17 | dependencies: 18 | "@babel/types" "^7.23.0" 19 | "@jridgewell/gen-mapping" "^0.3.2" 20 | "@jridgewell/trace-mapping" "^0.3.17" 21 | jsesc "^2.5.1" 22 | 23 | "@babel/helper-environment-visitor@^7.22.20": 24 | version "7.22.20" 25 | resolved "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz" 26 | integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== 27 | 28 | "@babel/helper-function-name@^7.23.0": 29 | version "7.23.0" 30 | resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz" 31 | integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== 32 | dependencies: 33 | "@babel/template" "^7.22.15" 34 | "@babel/types" "^7.23.0" 35 | 36 | "@babel/helper-hoist-variables@^7.22.5": 37 | version "7.22.5" 38 | resolved "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz" 39 | integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== 40 | dependencies: 41 | "@babel/types" "^7.22.5" 42 | 43 | "@babel/helper-split-export-declaration@^7.22.6": 44 | version "7.22.6" 45 | resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz" 46 | integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g== 47 | dependencies: 48 | "@babel/types" "^7.22.5" 49 | 50 | "@babel/helper-string-parser@^7.22.5": 51 | version "7.22.5" 52 | resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz" 53 | integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== 54 | 55 | "@babel/helper-validator-identifier@^7.22.20": 56 | version "7.22.20" 57 | resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz" 58 | integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== 59 | 60 | "@babel/highlight@^7.22.13": 61 | version "7.22.20" 62 | resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz" 63 | integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg== 64 | dependencies: 65 | "@babel/helper-validator-identifier" "^7.22.20" 66 | chalk "^2.4.2" 67 | js-tokens "^4.0.0" 68 | 69 | "@babel/parser@^7.22.15", "@babel/parser@^7.23.0", "@babel/parser@^7.7.0": 70 | version "7.23.0" 71 | resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz" 72 | integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw== 73 | 74 | "@babel/template@^7.22.15": 75 | version "7.22.15" 76 | resolved "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz" 77 | integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w== 78 | dependencies: 79 | "@babel/code-frame" "^7.22.13" 80 | "@babel/parser" "^7.22.15" 81 | "@babel/types" "^7.22.15" 82 | 83 | "@babel/traverse@^7.7.0": 84 | version "7.23.2" 85 | resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz" 86 | integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw== 87 | dependencies: 88 | "@babel/code-frame" "^7.22.13" 89 | "@babel/generator" "^7.23.0" 90 | "@babel/helper-environment-visitor" "^7.22.20" 91 | "@babel/helper-function-name" "^7.23.0" 92 | "@babel/helper-hoist-variables" "^7.22.5" 93 | "@babel/helper-split-export-declaration" "^7.22.6" 94 | "@babel/parser" "^7.23.0" 95 | "@babel/types" "^7.23.0" 96 | debug "^4.1.0" 97 | globals "^11.1.0" 98 | 99 | "@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.7.0": 100 | version "7.23.0" 101 | resolved "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz" 102 | integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg== 103 | dependencies: 104 | "@babel/helper-string-parser" "^7.22.5" 105 | "@babel/helper-validator-identifier" "^7.22.20" 106 | to-fast-properties "^2.0.0" 107 | 108 | "@jridgewell/gen-mapping@^0.3.2": 109 | version "0.3.3" 110 | resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz" 111 | integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ== 112 | dependencies: 113 | "@jridgewell/set-array" "^1.0.1" 114 | "@jridgewell/sourcemap-codec" "^1.4.10" 115 | "@jridgewell/trace-mapping" "^0.3.9" 116 | 117 | "@jridgewell/resolve-uri@^3.1.0": 118 | version "3.1.1" 119 | resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz" 120 | integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== 121 | 122 | "@jridgewell/set-array@^1.0.1": 123 | version "1.1.2" 124 | resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz" 125 | integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== 126 | 127 | "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": 128 | version "1.4.15" 129 | resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz" 130 | integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== 131 | 132 | "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": 133 | version "0.3.20" 134 | resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz" 135 | integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q== 136 | dependencies: 137 | "@jridgewell/resolve-uri" "^3.1.0" 138 | "@jridgewell/sourcemap-codec" "^1.4.14" 139 | 140 | "@types/color-name@^1.1.1": 141 | version "1.1.1" 142 | resolved "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz" 143 | integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== 144 | 145 | acorn-jsx@^5.2.0: 146 | version "5.2.0" 147 | resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.2.0.tgz" 148 | integrity sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ== 149 | 150 | "acorn@^6.0.0 || ^7.0.0", acorn@^7.1.1: 151 | version "7.1.1" 152 | resolved "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz" 153 | integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg== 154 | 155 | ajv@^6.10.0, ajv@^6.10.2: 156 | version "6.12.6" 157 | resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" 158 | integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== 159 | dependencies: 160 | fast-deep-equal "^3.1.1" 161 | fast-json-stable-stringify "^2.0.0" 162 | json-schema-traverse "^0.4.1" 163 | uri-js "^4.2.2" 164 | 165 | ansi-escapes@^4.2.1: 166 | version "4.3.1" 167 | resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz" 168 | integrity sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA== 169 | dependencies: 170 | type-fest "^0.11.0" 171 | 172 | ansi-regex@^4.1.0: 173 | version "4.1.1" 174 | resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz" 175 | integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== 176 | 177 | ansi-regex@^5.0.0: 178 | version "5.0.1" 179 | resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" 180 | integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== 181 | 182 | ansi-styles@^3.2.0, ansi-styles@^3.2.1: 183 | version "3.2.1" 184 | resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz" 185 | integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== 186 | dependencies: 187 | color-convert "^1.9.0" 188 | 189 | ansi-styles@^4.1.0: 190 | version "4.2.1" 191 | resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz" 192 | integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== 193 | dependencies: 194 | "@types/color-name" "^1.1.1" 195 | color-convert "^2.0.1" 196 | 197 | argparse@^1.0.7: 198 | version "1.0.10" 199 | resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz" 200 | integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== 201 | dependencies: 202 | sprintf-js "~1.0.2" 203 | 204 | astral-regex@^1.0.0: 205 | version "1.0.0" 206 | resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz" 207 | integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== 208 | 209 | babel-eslint@^10.1.0: 210 | version "10.1.0" 211 | resolved "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz" 212 | integrity sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg== 213 | dependencies: 214 | "@babel/code-frame" "^7.0.0" 215 | "@babel/parser" "^7.7.0" 216 | "@babel/traverse" "^7.7.0" 217 | "@babel/types" "^7.7.0" 218 | eslint-visitor-keys "^1.0.0" 219 | resolve "^1.12.0" 220 | 221 | balanced-match@^1.0.0: 222 | version "1.0.0" 223 | resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz" 224 | integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= 225 | 226 | brace-expansion@^1.1.7: 227 | version "1.1.11" 228 | resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" 229 | integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== 230 | dependencies: 231 | balanced-match "^1.0.0" 232 | concat-map "0.0.1" 233 | 234 | callsites@^3.0.0: 235 | version "3.1.0" 236 | resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" 237 | integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== 238 | 239 | chalk@^2.1.0, chalk@^2.4.2: 240 | version "2.4.2" 241 | resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" 242 | integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== 243 | dependencies: 244 | ansi-styles "^3.2.1" 245 | escape-string-regexp "^1.0.5" 246 | supports-color "^5.3.0" 247 | 248 | chalk@^3.0.0: 249 | version "3.0.0" 250 | resolved "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz" 251 | integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== 252 | dependencies: 253 | ansi-styles "^4.1.0" 254 | supports-color "^7.1.0" 255 | 256 | chardet@^0.7.0: 257 | version "0.7.0" 258 | resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz" 259 | integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== 260 | 261 | cli-cursor@^3.1.0: 262 | version "3.1.0" 263 | resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz" 264 | integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== 265 | dependencies: 266 | restore-cursor "^3.1.0" 267 | 268 | cli-width@^2.0.0: 269 | version "2.2.1" 270 | resolved "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz" 271 | integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== 272 | 273 | color-convert@^1.9.0: 274 | version "1.9.3" 275 | resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz" 276 | integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 277 | dependencies: 278 | color-name "1.1.3" 279 | 280 | color-convert@^2.0.1: 281 | version "2.0.1" 282 | resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" 283 | integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== 284 | dependencies: 285 | color-name "~1.1.4" 286 | 287 | color-name@~1.1.4: 288 | version "1.1.4" 289 | resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" 290 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 291 | 292 | color-name@1.1.3: 293 | version "1.1.3" 294 | resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" 295 | integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= 296 | 297 | concat-map@0.0.1: 298 | version "0.0.1" 299 | resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" 300 | integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= 301 | 302 | cross-spawn@^6.0.5: 303 | version "6.0.5" 304 | resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz" 305 | integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== 306 | dependencies: 307 | nice-try "^1.0.4" 308 | path-key "^2.0.1" 309 | semver "^5.5.0" 310 | shebang-command "^1.2.0" 311 | which "^1.2.9" 312 | 313 | debug@^4.0.1, debug@^4.1.0: 314 | version "4.3.4" 315 | resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" 316 | integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== 317 | dependencies: 318 | ms "2.1.2" 319 | 320 | deep-is@~0.1.3: 321 | version "0.1.3" 322 | resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz" 323 | integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= 324 | 325 | doctrine@^3.0.0: 326 | version "3.0.0" 327 | resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" 328 | integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== 329 | dependencies: 330 | esutils "^2.0.2" 331 | 332 | emoji-regex@^7.0.1: 333 | version "7.0.3" 334 | resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz" 335 | integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== 336 | 337 | emoji-regex@^8.0.0: 338 | version "8.0.0" 339 | resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" 340 | integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== 341 | 342 | escape-string-regexp@^1.0.5: 343 | version "1.0.5" 344 | resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" 345 | integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= 346 | 347 | eslint-config-google@^0.14.0: 348 | version "0.14.0" 349 | resolved "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz" 350 | integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw== 351 | 352 | eslint-scope@^5.0.0: 353 | version "5.0.0" 354 | resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz" 355 | integrity sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw== 356 | dependencies: 357 | esrecurse "^4.1.0" 358 | estraverse "^4.1.1" 359 | 360 | eslint-utils@^1.4.3: 361 | version "1.4.3" 362 | resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz" 363 | integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== 364 | dependencies: 365 | eslint-visitor-keys "^1.1.0" 366 | 367 | eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0: 368 | version "1.1.0" 369 | resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz" 370 | integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== 371 | 372 | eslint@^6.8.0, "eslint@>= 4.12.1", eslint@>=5.16.0: 373 | version "6.8.0" 374 | resolved "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz" 375 | integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig== 376 | dependencies: 377 | "@babel/code-frame" "^7.0.0" 378 | ajv "^6.10.0" 379 | chalk "^2.1.0" 380 | cross-spawn "^6.0.5" 381 | debug "^4.0.1" 382 | doctrine "^3.0.0" 383 | eslint-scope "^5.0.0" 384 | eslint-utils "^1.4.3" 385 | eslint-visitor-keys "^1.1.0" 386 | espree "^6.1.2" 387 | esquery "^1.0.1" 388 | esutils "^2.0.2" 389 | file-entry-cache "^5.0.1" 390 | functional-red-black-tree "^1.0.1" 391 | glob-parent "^5.0.0" 392 | globals "^12.1.0" 393 | ignore "^4.0.6" 394 | import-fresh "^3.0.0" 395 | imurmurhash "^0.1.4" 396 | inquirer "^7.0.0" 397 | is-glob "^4.0.0" 398 | js-yaml "^3.13.1" 399 | json-stable-stringify-without-jsonify "^1.0.1" 400 | levn "^0.3.0" 401 | lodash "^4.17.14" 402 | minimatch "^3.0.4" 403 | mkdirp "^0.5.1" 404 | natural-compare "^1.4.0" 405 | optionator "^0.8.3" 406 | progress "^2.0.0" 407 | regexpp "^2.0.1" 408 | semver "^6.1.2" 409 | strip-ansi "^5.2.0" 410 | strip-json-comments "^3.0.1" 411 | table "^5.2.3" 412 | text-table "^0.2.0" 413 | v8-compile-cache "^2.0.3" 414 | 415 | espree@^6.1.2: 416 | version "6.2.1" 417 | resolved "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz" 418 | integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw== 419 | dependencies: 420 | acorn "^7.1.1" 421 | acorn-jsx "^5.2.0" 422 | eslint-visitor-keys "^1.1.0" 423 | 424 | esprima@^4.0.0: 425 | version "4.0.1" 426 | resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" 427 | integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== 428 | 429 | esquery@^1.0.1: 430 | version "1.3.1" 431 | resolved "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz" 432 | integrity sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ== 433 | dependencies: 434 | estraverse "^5.1.0" 435 | 436 | esrecurse@^4.1.0: 437 | version "4.2.1" 438 | resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz" 439 | integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ== 440 | dependencies: 441 | estraverse "^4.1.0" 442 | 443 | estraverse@^4.1.0, estraverse@^4.1.1: 444 | version "4.3.0" 445 | resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz" 446 | integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== 447 | 448 | estraverse@^5.1.0: 449 | version "5.1.0" 450 | resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz" 451 | integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== 452 | 453 | esutils@^2.0.2: 454 | version "2.0.3" 455 | resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" 456 | integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== 457 | 458 | external-editor@^3.0.3: 459 | version "3.1.0" 460 | resolved "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz" 461 | integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== 462 | dependencies: 463 | chardet "^0.7.0" 464 | iconv-lite "^0.4.24" 465 | tmp "^0.0.33" 466 | 467 | fast-deep-equal@^3.1.1: 468 | version "3.1.1" 469 | resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz" 470 | integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA== 471 | 472 | fast-json-stable-stringify@^2.0.0: 473 | version "2.1.0" 474 | resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" 475 | integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== 476 | 477 | fast-levenshtein@~2.0.6: 478 | version "2.0.6" 479 | resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" 480 | integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= 481 | 482 | figures@^3.0.0: 483 | version "3.2.0" 484 | resolved "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz" 485 | integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== 486 | dependencies: 487 | escape-string-regexp "^1.0.5" 488 | 489 | file-entry-cache@^5.0.1: 490 | version "5.0.1" 491 | resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz" 492 | integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== 493 | dependencies: 494 | flat-cache "^2.0.1" 495 | 496 | flat-cache@^2.0.1: 497 | version "2.0.1" 498 | resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz" 499 | integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== 500 | dependencies: 501 | flatted "^2.0.0" 502 | rimraf "2.6.3" 503 | write "1.0.3" 504 | 505 | flatted@^2.0.0: 506 | version "2.0.2" 507 | resolved "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz" 508 | integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== 509 | 510 | fs.realpath@^1.0.0: 511 | version "1.0.0" 512 | resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" 513 | integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= 514 | 515 | function-bind@^1.1.1: 516 | version "1.1.1" 517 | resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" 518 | integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== 519 | 520 | functional-red-black-tree@^1.0.1: 521 | version "1.0.1" 522 | resolved "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz" 523 | integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= 524 | 525 | glob-parent@^5.0.0: 526 | version "5.1.2" 527 | resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" 528 | integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== 529 | dependencies: 530 | is-glob "^4.0.1" 531 | 532 | glob@^7.1.3: 533 | version "7.1.6" 534 | resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" 535 | integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== 536 | dependencies: 537 | fs.realpath "^1.0.0" 538 | inflight "^1.0.4" 539 | inherits "2" 540 | minimatch "^3.0.4" 541 | once "^1.3.0" 542 | path-is-absolute "^1.0.0" 543 | 544 | globals@^11.1.0: 545 | version "11.12.0" 546 | resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz" 547 | integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== 548 | 549 | globals@^12.1.0: 550 | version "12.4.0" 551 | resolved "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz" 552 | integrity sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg== 553 | dependencies: 554 | type-fest "^0.8.1" 555 | 556 | has-flag@^3.0.0: 557 | version "3.0.0" 558 | resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz" 559 | integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= 560 | 561 | has-flag@^4.0.0: 562 | version "4.0.0" 563 | resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" 564 | integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== 565 | 566 | has@^1.0.3: 567 | version "1.0.3" 568 | resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz" 569 | integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== 570 | dependencies: 571 | function-bind "^1.1.1" 572 | 573 | iconv-lite@^0.4.24: 574 | version "0.4.24" 575 | resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" 576 | integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 577 | dependencies: 578 | safer-buffer ">= 2.1.2 < 3" 579 | 580 | ignore@^4.0.6: 581 | version "4.0.6" 582 | resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz" 583 | integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== 584 | 585 | import-fresh@^3.0.0: 586 | version "3.2.1" 587 | resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz" 588 | integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ== 589 | dependencies: 590 | parent-module "^1.0.0" 591 | resolve-from "^4.0.0" 592 | 593 | imurmurhash@^0.1.4: 594 | version "0.1.4" 595 | resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" 596 | integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= 597 | 598 | inflight@^1.0.4: 599 | version "1.0.6" 600 | resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" 601 | integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= 602 | dependencies: 603 | once "^1.3.0" 604 | wrappy "1" 605 | 606 | inherits@2: 607 | version "2.0.4" 608 | resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" 609 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 610 | 611 | inquirer@^7.0.0: 612 | version "7.1.0" 613 | resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.1.0.tgz" 614 | integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg== 615 | dependencies: 616 | ansi-escapes "^4.2.1" 617 | chalk "^3.0.0" 618 | cli-cursor "^3.1.0" 619 | cli-width "^2.0.0" 620 | external-editor "^3.0.3" 621 | figures "^3.0.0" 622 | lodash "^4.17.15" 623 | mute-stream "0.0.8" 624 | run-async "^2.4.0" 625 | rxjs "^6.5.3" 626 | string-width "^4.1.0" 627 | strip-ansi "^6.0.0" 628 | through "^2.3.6" 629 | 630 | is-core-module@^2.8.1: 631 | version "2.9.0" 632 | resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz" 633 | integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== 634 | dependencies: 635 | has "^1.0.3" 636 | 637 | is-extglob@^2.1.1: 638 | version "2.1.1" 639 | resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" 640 | integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= 641 | 642 | is-fullwidth-code-point@^2.0.0: 643 | version "2.0.0" 644 | resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz" 645 | integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= 646 | 647 | is-fullwidth-code-point@^3.0.0: 648 | version "3.0.0" 649 | resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" 650 | integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== 651 | 652 | is-glob@^4.0.0, is-glob@^4.0.1: 653 | version "4.0.1" 654 | resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz" 655 | integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== 656 | dependencies: 657 | is-extglob "^2.1.1" 658 | 659 | is-promise@^2.1.0: 660 | version "2.1.0" 661 | resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz" 662 | integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= 663 | 664 | isexe@^2.0.0: 665 | version "2.0.0" 666 | resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" 667 | integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= 668 | 669 | js-tokens@^4.0.0: 670 | version "4.0.0" 671 | resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" 672 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 673 | 674 | js-yaml@^3.13.1: 675 | version "3.13.1" 676 | resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz" 677 | integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== 678 | dependencies: 679 | argparse "^1.0.7" 680 | esprima "^4.0.0" 681 | 682 | jsesc@^2.5.1: 683 | version "2.5.2" 684 | resolved "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz" 685 | integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== 686 | 687 | json-schema-traverse@^0.4.1: 688 | version "0.4.1" 689 | resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" 690 | integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== 691 | 692 | json-stable-stringify-without-jsonify@^1.0.1: 693 | version "1.0.1" 694 | resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" 695 | integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= 696 | 697 | levn@^0.3.0, levn@~0.3.0: 698 | version "0.3.0" 699 | resolved "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz" 700 | integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= 701 | dependencies: 702 | prelude-ls "~1.1.2" 703 | type-check "~0.3.2" 704 | 705 | lodash@^4.17.14, lodash@^4.17.15: 706 | version "4.17.21" 707 | resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" 708 | integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 709 | 710 | mimic-fn@^2.1.0: 711 | version "2.1.0" 712 | resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" 713 | integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== 714 | 715 | minimatch@^3.0.4: 716 | version "3.1.2" 717 | resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" 718 | integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== 719 | dependencies: 720 | brace-expansion "^1.1.7" 721 | 722 | minimist@^1.2.5: 723 | version "1.2.8" 724 | resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" 725 | integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== 726 | 727 | mkdirp@^0.5.1: 728 | version "0.5.5" 729 | resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz" 730 | integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== 731 | dependencies: 732 | minimist "^1.2.5" 733 | 734 | ms@2.1.2: 735 | version "2.1.2" 736 | resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" 737 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 738 | 739 | mute-stream@0.0.8: 740 | version "0.0.8" 741 | resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz" 742 | integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== 743 | 744 | natural-compare@^1.4.0: 745 | version "1.4.0" 746 | resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" 747 | integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= 748 | 749 | nice-try@^1.0.4: 750 | version "1.0.5" 751 | resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz" 752 | integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== 753 | 754 | once@^1.3.0: 755 | version "1.4.0" 756 | resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" 757 | integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= 758 | dependencies: 759 | wrappy "1" 760 | 761 | onetime@^5.1.0: 762 | version "5.1.0" 763 | resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz" 764 | integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q== 765 | dependencies: 766 | mimic-fn "^2.1.0" 767 | 768 | optionator@^0.8.3: 769 | version "0.8.3" 770 | resolved "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz" 771 | integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== 772 | dependencies: 773 | deep-is "~0.1.3" 774 | fast-levenshtein "~2.0.6" 775 | levn "~0.3.0" 776 | prelude-ls "~1.1.2" 777 | type-check "~0.3.2" 778 | word-wrap "~1.2.3" 779 | 780 | os-tmpdir@~1.0.2: 781 | version "1.0.2" 782 | resolved "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz" 783 | integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= 784 | 785 | parent-module@^1.0.0: 786 | version "1.0.1" 787 | resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" 788 | integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== 789 | dependencies: 790 | callsites "^3.0.0" 791 | 792 | path-is-absolute@^1.0.0: 793 | version "1.0.1" 794 | resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" 795 | integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= 796 | 797 | path-key@^2.0.1: 798 | version "2.0.1" 799 | resolved "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz" 800 | integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= 801 | 802 | path-parse@^1.0.7: 803 | version "1.0.7" 804 | resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" 805 | integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== 806 | 807 | prelude-ls@~1.1.2: 808 | version "1.1.2" 809 | resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz" 810 | integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= 811 | 812 | progress@^2.0.0: 813 | version "2.0.3" 814 | resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz" 815 | integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== 816 | 817 | punycode@^2.1.0: 818 | version "2.1.1" 819 | resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz" 820 | integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== 821 | 822 | regexpp@^2.0.1: 823 | version "2.0.1" 824 | resolved "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz" 825 | integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== 826 | 827 | resolve-from@^4.0.0: 828 | version "4.0.0" 829 | resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" 830 | integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== 831 | 832 | resolve@^1.12.0: 833 | version "1.22.0" 834 | resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz" 835 | integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== 836 | dependencies: 837 | is-core-module "^2.8.1" 838 | path-parse "^1.0.7" 839 | supports-preserve-symlinks-flag "^1.0.0" 840 | 841 | restore-cursor@^3.1.0: 842 | version "3.1.0" 843 | resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz" 844 | integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== 845 | dependencies: 846 | onetime "^5.1.0" 847 | signal-exit "^3.0.2" 848 | 849 | rimraf@2.6.3: 850 | version "2.6.3" 851 | resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz" 852 | integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== 853 | dependencies: 854 | glob "^7.1.3" 855 | 856 | run-async@^2.4.0: 857 | version "2.4.0" 858 | resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.0.tgz" 859 | integrity sha512-xJTbh/d7Lm7SBhc1tNvTpeCHaEzoyxPrqNlvSdMfBTYwaY++UJFyXUOxAtsRUXjlqOfj8luNaR9vjCh4KeV+pg== 860 | dependencies: 861 | is-promise "^2.1.0" 862 | 863 | rxjs@^6.5.3: 864 | version "6.5.5" 865 | resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz" 866 | integrity sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ== 867 | dependencies: 868 | tslib "^1.9.0" 869 | 870 | "safer-buffer@>= 2.1.2 < 3": 871 | version "2.1.2" 872 | resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" 873 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 874 | 875 | semver@^5.5.0: 876 | version "5.7.2" 877 | resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" 878 | integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== 879 | 880 | semver@^6.1.2: 881 | version "6.3.1" 882 | resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" 883 | integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== 884 | 885 | shebang-command@^1.2.0: 886 | version "1.2.0" 887 | resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz" 888 | integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= 889 | dependencies: 890 | shebang-regex "^1.0.0" 891 | 892 | shebang-regex@^1.0.0: 893 | version "1.0.0" 894 | resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz" 895 | integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= 896 | 897 | signal-exit@^3.0.2: 898 | version "3.0.3" 899 | resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz" 900 | integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== 901 | 902 | slice-ansi@^2.1.0: 903 | version "2.1.0" 904 | resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz" 905 | integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== 906 | dependencies: 907 | ansi-styles "^3.2.0" 908 | astral-regex "^1.0.0" 909 | is-fullwidth-code-point "^2.0.0" 910 | 911 | sprintf-js@~1.0.2: 912 | version "1.0.3" 913 | resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" 914 | integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= 915 | 916 | string-width@^3.0.0: 917 | version "3.1.0" 918 | resolved "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz" 919 | integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== 920 | dependencies: 921 | emoji-regex "^7.0.1" 922 | is-fullwidth-code-point "^2.0.0" 923 | strip-ansi "^5.1.0" 924 | 925 | string-width@^4.1.0: 926 | version "4.2.0" 927 | resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz" 928 | integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== 929 | dependencies: 930 | emoji-regex "^8.0.0" 931 | is-fullwidth-code-point "^3.0.0" 932 | strip-ansi "^6.0.0" 933 | 934 | strip-ansi@^5.1.0, strip-ansi@^5.2.0: 935 | version "5.2.0" 936 | resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz" 937 | integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== 938 | dependencies: 939 | ansi-regex "^4.1.0" 940 | 941 | strip-ansi@^6.0.0: 942 | version "6.0.0" 943 | resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz" 944 | integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== 945 | dependencies: 946 | ansi-regex "^5.0.0" 947 | 948 | strip-json-comments@^3.0.1: 949 | version "3.1.0" 950 | resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz" 951 | integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== 952 | 953 | supports-color@^5.3.0: 954 | version "5.5.0" 955 | resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz" 956 | integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== 957 | dependencies: 958 | has-flag "^3.0.0" 959 | 960 | supports-color@^7.1.0: 961 | version "7.1.0" 962 | resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz" 963 | integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== 964 | dependencies: 965 | has-flag "^4.0.0" 966 | 967 | supports-preserve-symlinks-flag@^1.0.0: 968 | version "1.0.0" 969 | resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" 970 | integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== 971 | 972 | table@^5.2.3: 973 | version "5.4.6" 974 | resolved "https://registry.npmjs.org/table/-/table-5.4.6.tgz" 975 | integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug== 976 | dependencies: 977 | ajv "^6.10.2" 978 | lodash "^4.17.14" 979 | slice-ansi "^2.1.0" 980 | string-width "^3.0.0" 981 | 982 | text-table@^0.2.0: 983 | version "0.2.0" 984 | resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" 985 | integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= 986 | 987 | through@^2.3.6: 988 | version "2.3.8" 989 | resolved "https://registry.npmjs.org/through/-/through-2.3.8.tgz" 990 | integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= 991 | 992 | tmp@^0.0.33: 993 | version "0.0.33" 994 | resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz" 995 | integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== 996 | dependencies: 997 | os-tmpdir "~1.0.2" 998 | 999 | to-fast-properties@^2.0.0: 1000 | version "2.0.0" 1001 | resolved "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz" 1002 | integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= 1003 | 1004 | tslib@^1.9.0: 1005 | version "1.11.1" 1006 | resolved "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz" 1007 | integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== 1008 | 1009 | type-check@~0.3.2: 1010 | version "0.3.2" 1011 | resolved "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz" 1012 | integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= 1013 | dependencies: 1014 | prelude-ls "~1.1.2" 1015 | 1016 | type-fest@^0.11.0: 1017 | version "0.11.0" 1018 | resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz" 1019 | integrity sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ== 1020 | 1021 | type-fest@^0.8.1: 1022 | version "0.8.1" 1023 | resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz" 1024 | integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== 1025 | 1026 | uri-js@^4.2.2: 1027 | version "4.2.2" 1028 | resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz" 1029 | integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== 1030 | dependencies: 1031 | punycode "^2.1.0" 1032 | 1033 | v8-compile-cache@^2.0.3: 1034 | version "2.1.0" 1035 | resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz" 1036 | integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== 1037 | 1038 | web-vitals@^4.2.3: 1039 | version "4.2.3" 1040 | resolved "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.3.tgz" 1041 | integrity sha512-/CFAm1mNxSmOj6i0Co+iGFJ58OS4NRGVP+AWS/l509uIK5a1bSoIVaHz/ZumpHTfHSZBpgrJ+wjfpAOrTHok5Q== 1042 | 1043 | which@^1.2.9: 1044 | version "1.3.1" 1045 | resolved "https://registry.npmjs.org/which/-/which-1.3.1.tgz" 1046 | integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== 1047 | dependencies: 1048 | isexe "^2.0.0" 1049 | 1050 | word-wrap@~1.2.3: 1051 | version "1.2.5" 1052 | resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" 1053 | integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== 1054 | 1055 | wrappy@1: 1056 | version "1.0.2" 1057 | resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" 1058 | integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= 1059 | 1060 | write@1.0.3: 1061 | version "1.0.3" 1062 | resolved "https://registry.npmjs.org/write/-/write-1.0.3.tgz" 1063 | integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== 1064 | dependencies: 1065 | mkdirp "^0.5.1" 1066 | --------------------------------------------------------------------------------