",
- "nodeLabel": "Decision-making\nsupport\nย \nValues alignment\nexercises\nย \nMembership\nguidance\nย \nCoโฆ",
- "explanation": "Fix any of the following:\n Element has children which are not allowed: a[aria-label]"
- },
- "subItems": {
- "type": "subitems",
- "items": [
- {
- "relatedNode": {
- "type": "node",
- "lhId": "1-3-A",
- "path": "1,HTML,1,BODY,0,DIV,2,MAIN,0,DIV,5,SECTION,0,DIV,0,DIV,1,DIV,0,A",
- "selector": "div.py-[var(--spacing-scale-032)] > div.w-full > div.grid > a.block",
- "boundingRect": {
- "top": 2733,
- "bottom": 2919,
- "left": 20,
- "right": 200,
- "width": 180,
- "height": 186
- },
- "snippet": "
",
- "nodeLabel": "Membership\nguidance\nย "
- }
- }
- ]
- }
- }
- ],
- "debugData": {
- "type": "debugdata",
- "impact": "critical",
- "tags": ["cat.aria", "wcag2a", "wcag131", "EN-301-549", "EN-9.1.3.1"]
- }
- }
- },
- "aria-required-parent": {
- "id": "aria-required-parent",
- "title": "`[role]`s are contained by their required parent element",
- "description": "Some ARIA child roles must be contained by specific parent roles to properly perform their intended accessibility functions. [Learn more about ARIA roles and required parent element](https://dequeuniversity.com/rules/axe/4.10/aria-required-parent).",
- "score": 1,
- "scoreDisplayMode": "binary",
- "details": {
- "type": "table",
- "headings": [],
- "items": []
- }
- },
- "aria-roles": {
- "id": "aria-roles",
- "title": "`[role]` values are valid",
- "description": "ARIA roles must have valid values in order to perform their intended accessibility functions. [Learn more about valid ARIA roles](https://dequeuniversity.com/rules/axe/4.10/aria-roles).",
- "score": 1,
- "scoreDisplayMode": "binary",
- "details": {
- "type": "table",
- "headings": [],
- "items": []
- }
- },
- "aria-text": {
- "id": "aria-text",
- "title": "Elements with the `role=text` attribute do not have focusable descendents.",
- "description": "Adding `role=text` around a text node split by markup enables VoiceOver to treat it as one phrase, but the element's focusable descendents will not be announced. [Learn more about the `role=text` attribute](https://dequeuniversity.com/rules/axe/4.10/aria-text).",
- "score": null,
- "scoreDisplayMode": "notApplicable"
- },
- "aria-toggle-field-name": {
- "id": "aria-toggle-field-name",
- "title": "ARIA toggle fields have accessible names",
- "description": "When a toggle field doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more about toggle fields](https://dequeuniversity.com/rules/axe/4.10/aria-toggle-field-name).",
- "score": null,
- "scoreDisplayMode": "notApplicable"
- },
- "aria-tooltip-name": {
- "id": "aria-tooltip-name",
- "title": "ARIA `tooltip` elements have accessible names",
- "description": "When a tooltip element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to name `tooltip` elements](https://dequeuniversity.com/rules/axe/4.10/aria-tooltip-name).",
- "score": null,
- "scoreDisplayMode": "notApplicable"
- },
- "aria-treeitem-name": {
- "id": "aria-treeitem-name",
- "title": "ARIA `treeitem` elements have accessible names",
- "description": "When a `treeitem` element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more about labeling `treeitem` elements](https://dequeuniversity.com/rules/axe/4.10/aria-treeitem-name).",
- "score": null,
- "scoreDisplayMode": "notApplicable"
- },
- "aria-valid-attr-value": {
- "id": "aria-valid-attr-value",
- "title": "`[aria-*]` attributes have valid values",
- "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid values. [Learn more about valid values for ARIA attributes](https://dequeuniversity.com/rules/axe/4.10/aria-valid-attr-value).",
- "score": 1,
- "scoreDisplayMode": "binary",
- "details": {
- "type": "table",
- "headings": [],
- "items": []
- }
- },
- "aria-valid-attr": {
- "id": "aria-valid-attr",
- "title": "`[aria-*]` attributes are valid and not misspelled",
- "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid names. [Learn more about valid ARIA attributes](https://dequeuniversity.com/rules/axe/4.10/aria-valid-attr).",
- "score": 1,
- "scoreDisplayMode": "binary",
- "details": {
- "type": "table",
- "headings": [],
- "items": []
- }
- },
- "button-name": {
- "id": "button-name",
- "title": "Buttons have an accessible name",
- "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn how to make buttons more accessible](https://dequeuniversity.com/rules/axe/4.10/button-name).",
- "score": 1,
- "scoreDisplayMode": "binary",
- "details": {
- "type": "table",
- "headings": [],
- "items": []
- }
- },
- "bypass": {
- "id": "bypass",
- "title": "The page contains a heading, skip link, or landmark region",
- "description": "Adding ways to bypass repetitive content lets keyboard users navigate the page more efficiently. [Learn more about bypass blocks](https://dequeuniversity.com/rules/axe/4.10/bypass).",
- "score": null,
- "scoreDisplayMode": "notApplicable"
- },
- "color-contrast": {
- "id": "color-contrast",
- "title": "Background and foreground colors do not have a sufficient contrast ratio.",
- "description": "Low-contrast text is difficult or impossible for many users to read. [Learn how to provide sufficient color contrast](https://dequeuniversity.com/rules/axe/4.10/color-contrast).",
- "score": 0,
- "scoreDisplayMode": "binary",
- "details": {
- "type": "table",
- "headings": [
- {
- "key": "node",
- "valueType": "node",
- "subItemsHeading": {
- "key": "relatedNode",
- "valueType": "node"
- },
- "label": "Failing Elements"
- }
- ],
- "items": [
- {
- "node": {
- "type": "node",
- "lhId": "1-6-P",
- "path": "1,HTML,1,BODY,0,DIV,2,MAIN,0,DIV,3,SECTION,0,DIV,0,DIV,0,DIV,0,DIV,1,DIV,0,P",
- "selector": "div > div.flex > div.lg:w-[928px] > p.font-inter",
- "boundingRect": {
- "top": 913,
- "bottom": 960,
- "left": 20,
- "right": 392,
- "width": 372,
- "height": 47
- },
- "snippet": "",
- "nodeLabel": "Here's a quick overview of the process, from start to finish.",
- "explanation": "Fix any of the following:\n Element has insufficient color contrast of 2.29 (foreground color: #484848, background color: #000000, font size: 13.5pt (18px), font weight: normal). Expected contrast ratio of 4.5:1"
- },
- "subItems": {
- "type": "subitems",
- "items": [
- {
- "relatedNode": {
- "type": "node",
- "lhId": "1-7-BODY",
- "path": "1,HTML,1,BODY",
- "selector": "body.inter_92071f97-module__lwgcdq__variable",
- "boundingRect": {
- "top": 0,
- "bottom": 4509,
- "left": 0,
- "right": 412,
- "width": 412,
- "height": 4509
- },
- "snippet": "
",
- "nodeLabel": "body.inter_92071f97-module__lwgcdq__variable"
- }
- }
- ]
- }
- }
- ],
- "debugData": {
- "type": "debugdata",
- "impact": "serious",
- "tags": [
- "cat.color",
- "wcag2aa",
- "wcag143",
- "TTv5",
- "TT13.c",
- "EN-301-549",
- "EN-9.1.4.3",
- "ACT"
- ]
- }
- }
- },
- "definition-list": {
- "id": "definition-list",
- "title": "``'s contain only properly-ordered `- ` and `
- ` groups, `
-
+
-
-
+ __initLighthouseReport__();
+ //# sourceURL=compiled-reportrenderer.js
+
+
+
diff --git a/lhci-results/127_0_0_1--2025_09_30_21_09_10.report.html b/lhci-results/127_0_0_1--2025_09_30_21_09_10.report.html
index 50ef4e3..ca1ab34 100644
--- a/lhci-results/127_0_0_1--2025_09_30_21_09_10.report.html
+++ b/lhci-results/127_0_0_1--2025_09_30_21_09_10.report.html
@@ -5,20 +5,11885 @@ SPDX-License-Identifier: Apache-2.0
-->
-
-
-
-
- Lighthouse Report
-
-
-
-
+
+
+
+
+ Lighthouse Report
+
+
+
+
-
+
-
-
+
-
-
+ __initLighthouseReport__();
+ //# sourceURL=compiled-reportrenderer.js
+
+
+
diff --git a/lhci-results/127_0_0_1--2025_09_30_21_09_25.report.html b/lhci-results/127_0_0_1--2025_09_30_21_09_25.report.html
index efd62f7..6e19692 100644
--- a/lhci-results/127_0_0_1--2025_09_30_21_09_25.report.html
+++ b/lhci-results/127_0_0_1--2025_09_30_21_09_25.report.html
@@ -5,20 +5,11897 @@ SPDX-License-Identifier: Apache-2.0
-->
-
-
-
-
- Lighthouse Report
-
-
-
-
+
+
+
+
+ Lighthouse Report
+
+
+
+
-
+
-
-
+
-
-
+ __initLighthouseReport__();
+ //# sourceURL=compiled-reportrenderer.js
+
+
+
diff --git a/lhci-results/127_0_0_1--2025_09_30_23_00_30.report.html b/lhci-results/127_0_0_1--2025_09_30_23_00_30.report.html
index 2c9dd89..578ad5f 100644
--- a/lhci-results/127_0_0_1--2025_09_30_23_00_30.report.html
+++ b/lhci-results/127_0_0_1--2025_09_30_23_00_30.report.html
@@ -5,20 +5,11998 @@ SPDX-License-Identifier: Apache-2.0
-->
-
-
-
-
- Lighthouse Report
-
-
-
-
+
+
+
+
+ Lighthouse Report
+
+
+
+
-
+
-
-
+
-
-
+ __initLighthouseReport__();
+ //# sourceURL=compiled-reportrenderer.js
+
+
+
diff --git a/lhci-results/127_0_0_1--2025_09_30_23_00_46.report.html b/lhci-results/127_0_0_1--2025_09_30_23_00_46.report.html
index f0ed76b..f1d9f74 100644
--- a/lhci-results/127_0_0_1--2025_09_30_23_00_46.report.html
+++ b/lhci-results/127_0_0_1--2025_09_30_23_00_46.report.html
@@ -5,20 +5,11826 @@ SPDX-License-Identifier: Apache-2.0
-->
-
-
-
-
- Lighthouse Report
-
-
-
-
+
+
+
+
+ Lighthouse Report
+
+
+
+
-
+
-
-
+
-
-
+ __initLighthouseReport__();
+ //# sourceURL=compiled-reportrenderer.js
+
+
+
diff --git a/lhci-results/127_0_0_1--2025_09_30_23_01_01.report.html b/lhci-results/127_0_0_1--2025_09_30_23_01_01.report.html
index 10a5b47..ba29dde 100644
--- a/lhci-results/127_0_0_1--2025_09_30_23_01_01.report.html
+++ b/lhci-results/127_0_0_1--2025_09_30_23_01_01.report.html
@@ -5,20 +5,11939 @@ SPDX-License-Identifier: Apache-2.0
-->
-
-
-
-
- Lighthouse Report
-
-
-
-
+
+
+
+
+ Lighthouse Report
+
+
+
+
-
+
-
-
+
-
-
+ __initLighthouseReport__();
+ //# sourceURL=compiled-reportrenderer.js
+
+
+
diff --git a/next.config.mjs b/next.config.mjs
index 9f27b3d..988896c 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -5,12 +5,88 @@ const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
- webpack(config) {
+ // Performance optimizations
+ experimental: {
+ optimizeCss: true,
+ optimizePackageImports: ["react", "react-dom"],
+ },
+ // Compression
+ compress: true,
+ // Image optimization
+ images: {
+ formats: ["image/webp", "image/avif"],
+ minimumCacheTTL: 60,
+ dangerouslyAllowSVG: true,
+ contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
+ },
+ // Headers for caching
+ async headers() {
+ return [
+ {
+ source: "/(.*)",
+ headers: [
+ {
+ key: "X-Content-Type-Options",
+ value: "nosniff",
+ },
+ {
+ key: "X-Frame-Options",
+ value: "DENY",
+ },
+ {
+ key: "X-XSS-Protection",
+ value: "1; mode=block",
+ },
+ ],
+ },
+ {
+ source: "/static/(.*)",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, max-age=31536000, immutable",
+ },
+ ],
+ },
+ ];
+ },
+ webpack(config, { dev, isServer }) {
+ // SVG handling
config.module.rules.push({
test: /\.svg$/,
issuer: /\.[jt]sx?$/,
use: ["@svgr/webpack"],
});
+
+ // Bundle analysis - only in production builds
+ if (process.env.ANALYZE === "true" && !dev) {
+ try {
+ const BundleAnalyzerPlugin =
+ require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
+ config.plugins.push(
+ new BundleAnalyzerPlugin({
+ analyzerMode: "static",
+ openAnalyzer: false,
+ reportFilename: isServer
+ ? "../analyze/server.html"
+ : "../analyze/client.html",
+ }),
+ );
+ } catch (error) {
+ console.warn("Bundle analyzer not available:", error.message);
+ }
+ }
+
+ // Production optimizations
+ if (!dev && !isServer) {
+ // Tree shaking optimization
+ config.optimization = {
+ ...config.optimization,
+ usedExports: true,
+ sideEffects: false,
+ };
+ }
+
return config;
},
};
diff --git a/package-lock.json b/package-lock.json
index f7839de..0726e7f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^15.5.2",
+ "critters": "^0.0.23",
"gray-matter": "^4.0.3",
"next": "15.2.4",
"react": "^19.0.0",
@@ -59,7 +60,9 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
- "wait-on": "^8.0.4"
+ "wait-on": "^8.0.4",
+ "web-vitals": "^4.2.4",
+ "webpack-bundle-analyzer": "^4.10.1"
}
},
"node_modules/@adobe/css-tools": {
@@ -2210,6 +2213,16 @@
"node": ">=18"
}
},
+ "node_modules/@discoveryjs/json-ext": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
+ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
@@ -8022,6 +8035,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/acorn-walk": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -8097,7 +8123,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -8890,7 +8915,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
- "dev": true,
"license": "ISC"
},
"node_modules/brace-expansion": {
@@ -9185,7 +9209,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@@ -9491,7 +9514,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -9504,7 +9526,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/color-string": {
@@ -9778,6 +9799,95 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/critters": {
+ "version": "0.0.23",
+ "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.23.tgz",
+ "integrity": "sha512-/MCsQbuzTPA/ZTOjjyr2Na5o3lRpr8vd0MZE8tMP0OBNg/VrLxWHteVKalQ8KR+fBmUadbJLdoyEz9sT+q84qg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "chalk": "^4.1.0",
+ "css-select": "^5.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.2",
+ "htmlparser2": "^8.0.2",
+ "postcss": "^8.4.23",
+ "postcss-media-query-parser": "^0.2.3"
+ }
+ },
+ "node_modules/critters/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/critters/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/critters/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/critters/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/critters/node_modules/htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -9814,7 +9924,6 @@
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
@@ -9831,7 +9940,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
@@ -9846,7 +9954,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -9859,7 +9966,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@ -9875,7 +9981,6 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
@@ -9904,7 +10009,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
@@ -10076,6 +10180,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/debounce": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -10576,7 +10687,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -12765,6 +12875,22 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/gzip-size": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
+ "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "duplexer": "^0.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -12782,7 +12908,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -18108,7 +18233,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
@@ -18553,6 +18677,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/opener": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+ "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+ "dev": true,
+ "license": "(WTFPL OR MIT)",
+ "bin": {
+ "opener": "bin/opener-bin.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -19163,7 +19297,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -19188,6 +19321,12 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postcss-media-query-parser": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
+ "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
+ "license": "MIT"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -21534,7 +21673,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -23006,6 +23144,13 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/web-vitals": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
+ "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/webdriver-bidi-protocol": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz",
@@ -23023,6 +23168,93 @@
"node": ">=12"
}
},
+ "node_modules/webpack-bundle-analyzer": {
+ "version": "4.10.2",
+ "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
+ "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@discoveryjs/json-ext": "0.5.7",
+ "acorn": "^8.0.4",
+ "acorn-walk": "^8.0.0",
+ "commander": "^7.2.0",
+ "debounce": "^1.2.1",
+ "escape-string-regexp": "^4.0.0",
+ "gzip-size": "^6.0.0",
+ "html-escaper": "^2.0.2",
+ "opener": "^1.5.2",
+ "picocolors": "^1.0.0",
+ "sirv": "^2.0.3",
+ "ws": "^7.3.1"
+ },
+ "bin": {
+ "webpack-bundle-analyzer": "lib/bin/analyzer.js"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/sirv": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
+ "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/ws": {
+ "version": "7.5.10",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
+ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.3.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
diff --git a/package.json b/package.json
index afe21ef..5bb1068 100644
--- a/package.json
+++ b/package.json
@@ -33,12 +33,21 @@
"seed-snapshots:local": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium",
"visual:test": "npx playwright test tests/e2e/visual-regression.spec.ts",
"visual:update": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts",
- "visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui"
+ "visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui",
+ "analyze": "npm run analyze:browser && npm run analyze:server",
+ "analyze:server": "ANALYZE=true npm run build",
+ "analyze:browser": "BUNDLE_ANALYZE=true npm run build",
+ "bundle:analyze": "node scripts/bundle-analyzer.js",
+ "web-vitals:track": "node scripts/web-vitals-tracker.js",
+ "monitor:all": "npm run bundle:analyze && npm run performance:monitor && npm run web-vitals:track",
+ "test:performance": "node scripts/test-performance.js",
+ "test:performance:ci": "npm run test:performance"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^15.5.2",
+ "critters": "^0.0.23",
"gray-matter": "^4.0.3",
"next": "15.2.4",
"react": "^19.0.0",
@@ -86,6 +95,8 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
- "wait-on": "^8.0.4"
+ "wait-on": "^8.0.4",
+ "web-vitals": "^4.2.4",
+ "webpack-bundle-analyzer": "^4.10.1"
}
}
diff --git a/performance-budgets.json b/performance-budgets.json
index 6a5df61..1454f32 100644
--- a/performance-budgets.json
+++ b/performance-budgets.json
@@ -1,4 +1,41 @@
{
+ "budgets": [
+ {
+ "name": "lcp",
+ "maxValue": 2500,
+ "description": "Largest Contentful Paint should be under 2.5s"
+ },
+ {
+ "name": "fid",
+ "maxValue": 100,
+ "description": "First Input Delay should be under 100ms"
+ },
+ {
+ "name": "cls",
+ "maxValue": 0.1,
+ "description": "Cumulative Layout Shift should be under 0.1"
+ },
+ {
+ "name": "fcp",
+ "maxValue": 1800,
+ "description": "First Contentful Paint should be under 1.8s"
+ },
+ {
+ "name": "ttfb",
+ "maxValue": 800,
+ "description": "Time to First Byte should be under 800ms"
+ },
+ {
+ "name": "bundle-size",
+ "maxSizeKB": 500,
+ "description": "Individual bundle size should be under 500KB"
+ },
+ {
+ "name": "total-size",
+ "maxSizeKB": 2000,
+ "description": "Total bundle size should be under 2MB"
+ }
+ ],
"performance": {
"budgets": [
{
diff --git a/scripts/bundle-analyzer.js b/scripts/bundle-analyzer.js
new file mode 100644
index 0000000..27bdddf
--- /dev/null
+++ b/scripts/bundle-analyzer.js
@@ -0,0 +1,253 @@
+#!/usr/bin/env node
+
+/**
+ * Bundle Analysis Script
+ * Analyzes webpack bundles and provides detailed performance insights
+ */
+
+const { execSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const BUNDLE_ANALYSIS_DIR = path.join(__dirname, "..", ".next", "analyze");
+const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
+
+class BundleAnalyzer {
+ constructor() {
+ this.results = {
+ timestamp: new Date().toISOString(),
+ bundles: {},
+ recommendations: [],
+ budgetViolations: [],
+ };
+ }
+
+ /**
+ * Run bundle analysis using build output
+ */
+ async analyzeBundles() {
+ console.log("๐ Starting bundle analysis...");
+
+ try {
+ // Ensure analyze directory exists
+ if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
+ fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
+ }
+
+ // Build the project first
+ console.log("๐๏ธ Building project...");
+ execSync("npm run build", { stdio: "inherit" });
+
+ // Parse bundle stats from build output
+ await this.parseBundleStats();
+
+ // Check performance budgets
+ this.checkPerformanceBudgets();
+
+ // Generate recommendations
+ this.generateRecommendations();
+
+ // Save results
+ this.saveResults();
+
+ console.log("โ
Bundle analysis complete!");
+ console.log(`๐ Results saved to: ${BUNDLE_ANALYSIS_DIR}`);
+ } catch (error) {
+ console.error("โ Bundle analysis failed:", error.message);
+ process.exit(1);
+ }
+ }
+
+ /**
+ * Parse bundle statistics from build output
+ */
+ async parseBundleStats() {
+ const staticPath = path.join(__dirname, "..", ".next", "static");
+ const chunksPath = path.join(staticPath, "chunks");
+
+ // Analyze static assets
+ if (fs.existsSync(staticPath)) {
+ this.analyzeDirectory(staticPath, "static");
+ }
+
+ // Analyze chunks
+ if (fs.existsSync(chunksPath)) {
+ this.analyzeDirectory(chunksPath, "chunks");
+ }
+
+ // Analyze pages
+ const pagesPath = path.join(__dirname, "..", ".next", "server", "pages");
+ if (fs.existsSync(pagesPath)) {
+ this.analyzeDirectory(pagesPath, "pages");
+ }
+ }
+
+ /**
+ * Analyze directory for bundle sizes
+ */
+ analyzeDirectory(dirPath, type) {
+ const files = fs.readdirSync(dirPath);
+
+ files.forEach((file) => {
+ const filePath = path.join(dirPath, file);
+ const stats = fs.statSync(filePath);
+
+ if (stats.isFile() && (file.endsWith(".js") || file.endsWith(".css"))) {
+ const key = `${type}/${file}`;
+ this.results.bundles[key] = {
+ size: stats.size,
+ sizeKB: Math.round(stats.size / 1024),
+ lastModified: stats.mtime,
+ type: file.endsWith(".css") ? "css" : "js",
+ };
+ } else if (stats.isDirectory()) {
+ this.analyzeDirectory(filePath, `${type}/${file}`);
+ }
+ });
+ }
+
+ /**
+ * Check against performance budgets
+ */
+ checkPerformanceBudgets() {
+ const budgets = PERFORMANCE_BUDGETS.budgets || [];
+
+ Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
+ const budget = budgets.find(
+ (b) => filename.includes(b.name) || b.name === "all",
+ );
+
+ if (budget) {
+ if (bundle.sizeKB > budget.maxSizeKB) {
+ this.results.budgetViolations.push({
+ file: filename,
+ currentSize: bundle.sizeKB,
+ maxSize: budget.maxSizeKB,
+ overage: bundle.sizeKB - budget.maxSizeKB,
+ severity:
+ bundle.sizeKB > budget.maxSizeKB * 1.2 ? "high" : "medium",
+ });
+ }
+ } else {
+ // Default budget check for large files
+ if (bundle.sizeKB > 500) {
+ this.results.budgetViolations.push({
+ file: filename,
+ currentSize: bundle.sizeKB,
+ maxSize: 500,
+ overage: bundle.sizeKB - 500,
+ severity: bundle.sizeKB > 600 ? "high" : "medium",
+ });
+ }
+ }
+ });
+ }
+
+ /**
+ * Generate optimization recommendations
+ */
+ generateRecommendations() {
+ const recommendations = [];
+
+ // Check for large bundles
+ Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
+ if (bundle.sizeKB > 500) {
+ recommendations.push({
+ type: "large-bundle",
+ file: filename,
+ size: bundle.sizeKB,
+ suggestion:
+ "Consider code splitting or dynamic imports for this bundle",
+ });
+ }
+ });
+
+ // Check for budget violations
+ if (this.results.budgetViolations.length > 0) {
+ recommendations.push({
+ type: "budget-violation",
+ count: this.results.budgetViolations.length,
+ suggestion:
+ "Review and optimize bundles that exceed performance budgets",
+ });
+ }
+
+ // General recommendations
+ const totalSize = Object.values(this.results.bundles).reduce(
+ (sum, bundle) => sum + bundle.sizeKB,
+ 0,
+ );
+
+ if (totalSize > 2000) {
+ recommendations.push({
+ type: "total-size",
+ size: totalSize,
+ suggestion: "Consider implementing more aggressive code splitting",
+ });
+ }
+
+ this.results.recommendations = recommendations;
+ }
+
+ /**
+ * Save analysis results
+ */
+ saveResults() {
+ // Ensure directory exists
+ if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
+ fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
+ }
+
+ const resultsPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-analysis.json");
+ fs.writeFileSync(resultsPath, JSON.stringify(this.results, null, 2));
+
+ // Generate markdown report
+ this.generateMarkdownReport();
+ }
+
+ /**
+ * Generate markdown report
+ */
+ generateMarkdownReport() {
+ const reportPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-report.md");
+
+ let report = `# Bundle Analysis Report\n\n`;
+ report += `**Generated:** ${this.results.timestamp}\n\n`;
+
+ // Bundle sizes
+ report += `## Bundle Sizes\n\n`;
+ report += `| File | Size (KB) | Status |\n`;
+ report += `|------|-----------|--------|\n`;
+
+ Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
+ const status = bundle.sizeKB > 500 ? "โ ๏ธ Large" : "โ
Good";
+ report += `| ${filename} | ${bundle.sizeKB} | ${status} |\n`;
+ });
+
+ // Budget violations
+ if (this.results.budgetViolations.length > 0) {
+ report += `\n## Budget Violations\n\n`;
+ this.results.budgetViolations.forEach((violation) => {
+ report += `- **${violation.file}**: ${violation.currentSize}KB (exceeds ${violation.maxSize}KB by ${violation.overage}KB)\n`;
+ });
+ }
+
+ // Recommendations
+ if (this.results.recommendations.length > 0) {
+ report += `\n## Recommendations\n\n`;
+ this.results.recommendations.forEach((rec) => {
+ report += `- ${rec.suggestion}\n`;
+ });
+ }
+
+ fs.writeFileSync(reportPath, report);
+ }
+}
+
+// Run analysis if called directly
+if (require.main === module) {
+ const analyzer = new BundleAnalyzer();
+ analyzer.analyzeBundles().catch(console.error);
+}
+
+module.exports = BundleAnalyzer;
diff --git a/scripts/performance-monitor.js b/scripts/performance-monitor.js
index a94f9d7..d03ebed 100644
--- a/scripts/performance-monitor.js
+++ b/scripts/performance-monitor.js
@@ -2,386 +2,297 @@
/**
* Performance Monitoring Script
- *
- * This script provides comprehensive performance monitoring capabilities
- * for the Community Rule application.
+ * Monitors Core Web Vitals and performance metrics
*/
-const { spawn } = require("child_process");
+const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
-// Performance budgets
-const PERFORMANCE_BUDGETS = {
- page_load_time: 3000,
- first_contentful_paint: 2000,
- largest_contentful_paint: 2500,
- first_input_delay: 100,
- dns_lookup: 100,
- tcp_connection: 200,
- ttfb: 600,
- dom_content_loaded: 1500,
- full_load: 3000,
- component_render_time: 500,
- interaction_time: 100,
- scroll_performance: 50,
- network_request_duration: 1000,
- memory_usage_mb: 50,
-};
+const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
+const MONITORING_DIR = path.join(__dirname, "..", ".next", "monitoring");
-// Baseline metrics for regression detection
-const BASELINE_METRICS = {
- page_load_time: 2000,
- first_contentful_paint: 1500,
- largest_contentful_paint: 2000,
- first_input_delay: 50,
- dns_lookup: 50,
- tcp_connection: 100,
- ttfb: 400,
- dom_content_loaded: 1000,
- full_load: 2000,
- component_render_time: 300,
- interaction_time: 50,
- scroll_performance: 30,
- network_request_duration: 500,
- memory_usage_mb: 30,
-};
-
-class PerformanceMonitorScript {
+class PerformanceMonitor {
constructor() {
- this.metrics = new Map();
- this.regressions = [];
- this.warnings = [];
- }
-
- /**
- * Run Lighthouse CI performance tests
- */
- async runLighthouseCI() {
- console.log("๐ Running Lighthouse CI performance tests...");
-
- return new Promise((resolve, reject) => {
- const lhci = spawn("npx", ["lhci", "autorun"], {
- stdio: "pipe",
- shell: true,
- });
-
- let output = "";
- let errorOutput = "";
-
- lhci.stdout.on("data", (data) => {
- output += data.toString();
- console.log(data.toString());
- });
-
- lhci.stderr.on("data", (data) => {
- errorOutput += data.toString();
- console.error(data.toString());
- });
-
- lhci.on("close", (code) => {
- if (code === 0) {
- console.log("โ
Lighthouse CI tests completed successfully");
- this.analyzeLighthouseResults(output);
- resolve(output);
- } else {
- console.error("โ Lighthouse CI tests failed");
- reject(
- new Error(`Lighthouse CI failed with code ${code}: ${errorOutput}`),
- );
- }
- });
- });
- }
-
- /**
- * Run Playwright performance tests
- */
- async runPlaywrightPerformanceTests() {
- console.log("๐ญ Running Playwright performance tests...");
-
- return new Promise((resolve, reject) => {
- const playwright = spawn(
- "npx",
- [
- "playwright",
- "test",
- "tests/e2e/performance.spec.ts",
- "--reporter=json",
- ],
- {
- stdio: "pipe",
- shell: true,
- },
- );
-
- let output = "";
- let errorOutput = "";
-
- playwright.stdout.on("data", (data) => {
- output += data.toString();
- });
-
- playwright.stderr.on("data", (data) => {
- errorOutput += data.toString();
- });
-
- playwright.on("close", (code) => {
- if (code === 0) {
- console.log("โ
Playwright performance tests completed successfully");
- this.analyzePlaywrightResults(output);
- resolve(output);
- } else {
- console.error("โ Playwright performance tests failed");
- reject(
- new Error(
- `Playwright tests failed with code ${code}: ${errorOutput}`,
- ),
- );
- }
- });
- });
- }
-
- /**
- * Analyze Lighthouse CI results
- */
- analyzeLighthouseResults(output) {
- console.log("๐ Analyzing Lighthouse CI results...");
-
- // Parse Lighthouse results
- const lines = output.split("\n");
- let currentMetric = null;
-
- for (const line of lines) {
- if (line.includes("Performance")) {
- const scoreMatch = line.match(/(\d+)/);
- if (scoreMatch) {
- const score = parseInt(scoreMatch[1]);
- this.recordMetric("lighthouse_performance_score", score);
-
- if (score < 90) {
- this.warnings.push(
- `Performance score below threshold: ${score}/100`,
- );
- }
- }
- }
-
- if (line.includes("First Contentful Paint")) {
- const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
- if (timeMatch) {
- const time = parseFloat(timeMatch[1]);
- this.recordMetric("first_contentful_paint", time);
-
- if (time > PERFORMANCE_BUDGETS.first_contentful_paint) {
- this.warnings.push(
- `First Contentful Paint exceeded budget: ${time}ms`,
- );
- }
- }
- }
-
- if (line.includes("Largest Contentful Paint")) {
- const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
- if (timeMatch) {
- const time = parseFloat(timeMatch[1]);
- this.recordMetric("largest_contentful_paint", time);
-
- if (time > PERFORMANCE_BUDGETS.largest_contentful_paint) {
- this.warnings.push(
- `Largest Contentful Paint exceeded budget: ${time}ms`,
- );
- }
- }
- }
-
- if (line.includes("Total Blocking Time")) {
- const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
- if (timeMatch) {
- const time = parseFloat(timeMatch[1]);
- this.recordMetric("total_blocking_time", time);
-
- if (time > 300) {
- this.warnings.push(
- `Total Blocking Time exceeded budget: ${time}ms`,
- );
- }
- }
- }
-
- if (line.includes("Cumulative Layout Shift")) {
- const shiftMatch = line.match(/(\d+(?:\.\d+)?)/);
- if (shiftMatch) {
- const shift = parseFloat(shiftMatch[1]);
- this.recordMetric("cumulative_layout_shift", shift);
-
- if (shift > 0.1) {
- this.warnings.push(
- `Cumulative Layout Shift exceeded budget: ${shift}`,
- );
- }
- }
- }
- }
- }
-
- /**
- * Analyze Playwright test results
- */
- analyzePlaywrightResults(output) {
- console.log("๐ Analyzing Playwright test results...");
-
- try {
- const results = JSON.parse(output);
-
- for (const result of results) {
- if (result.status === "failed") {
- this.warnings.push(`Test failed: ${result.title}`);
- }
- }
- } catch (error) {
- console.warn("Could not parse Playwright results as JSON");
- }
- }
-
- /**
- * Record a performance metric
- */
- recordMetric(name, value) {
- if (!this.metrics.has(name)) {
- this.metrics.set(name, []);
- }
- this.metrics.get(name).push({
- value,
- timestamp: Date.now(),
- });
-
- // Check against baseline for regression detection
- const baseline = BASELINE_METRICS[name];
- if (baseline) {
- const regressionThreshold = baseline * 1.2; // 20% regression threshold
- if (value > regressionThreshold) {
- this.regressions.push({
- metric: name,
- current: value,
- baseline,
- regression: (((value - baseline) / baseline) * 100).toFixed(1) + "%",
- });
- }
- }
- }
-
- /**
- * Generate performance report
- */
- generateReport() {
- console.log("\n๐ Performance Monitoring Report");
- console.log("================================\n");
-
- // Summary
- console.log("๐ Summary:");
- console.log(`- Total metrics recorded: ${this.metrics.size}`);
- console.log(
- `- Performance regressions detected: ${this.regressions.length}`,
- );
- console.log(`- Warnings: ${this.warnings.length}\n`);
-
- // Performance regressions
- if (this.regressions.length > 0) {
- console.log("๐จ Performance Regressions:");
- for (const regression of this.regressions) {
- console.log(
- ` - ${regression.metric}: ${regression.current} (baseline: ${regression.baseline}, regression: ${regression.regression})`,
- );
- }
- console.log("");
- }
-
- // Warnings
- if (this.warnings.length > 0) {
- console.log("โ ๏ธ Warnings:");
- for (const warning of this.warnings) {
- console.log(` - ${warning}`);
- }
- console.log("");
- }
-
- // Metrics summary
- console.log("๐ Metrics Summary:");
- for (const [name, values] of this.metrics) {
- const latest = values[values.length - 1];
- const average =
- values.reduce((sum, v) => sum + v.value, 0) / values.length;
- const budget = PERFORMANCE_BUDGETS[name];
-
- console.log(` - ${name}:`);
- console.log(` Latest: ${latest.value}`);
- console.log(` Average: ${average.toFixed(2)}`);
- if (budget) {
- const status = latest.value <= budget ? "โ
" : "โ";
- console.log(` Budget: ${budget} ${status}`);
- }
- }
-
- // Save report to file
- const report = {
+ this.metrics = {
timestamp: new Date().toISOString(),
- summary: {
- totalMetrics: this.metrics.size,
- regressions: this.regressions.length,
- warnings: this.warnings.length,
- },
- regressions: this.regressions,
- warnings: this.warnings,
- metrics: Object.fromEntries(this.metrics),
+ coreWebVitals: {},
+ bundleMetrics: {},
+ recommendations: [],
};
-
- const reportPath = path.join(__dirname, "../performance-report.json");
- fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
- console.log(`\n๐ Report saved to: ${reportPath}`);
-
- return report;
}
/**
- * Run all performance monitoring
+ * Run comprehensive performance monitoring
*/
- async run() {
- console.log("๐ Starting Performance Monitoring...\n");
+ async monitorPerformance() {
+ console.log("๐ Starting performance monitoring...");
try {
- // Run Lighthouse CI tests
+ // Ensure monitoring directory exists
+ if (!fs.existsSync(MONITORING_DIR)) {
+ fs.mkdirSync(MONITORING_DIR, { recursive: true });
+ }
+
+ // Run Lighthouse CI for Core Web Vitals
await this.runLighthouseCI();
- // Run Playwright performance tests
- await this.runPlaywrightPerformanceTests();
+ // Analyze bundle performance
+ await this.analyzeBundlePerformance();
- // Generate and display report
- const report = this.generateReport();
+ // Check performance budgets
+ this.checkPerformanceBudgets();
- // Exit with appropriate code
- if (this.regressions.length > 0) {
- console.log("โ Performance regressions detected!");
- process.exit(1);
- } else if (this.warnings.length > 0) {
- console.log("โ ๏ธ Performance warnings detected.");
- process.exit(0);
- } else {
- console.log("โ
All performance checks passed!");
- process.exit(0);
- }
+ // Generate performance report
+ this.generatePerformanceReport();
+
+ console.log("โ
Performance monitoring complete!");
+ console.log(`๐ Results saved to: ${MONITORING_DIR}`);
} catch (error) {
console.error("โ Performance monitoring failed:", error.message);
process.exit(1);
}
}
+
+ /**
+ * Run Lighthouse CI for Core Web Vitals
+ */
+ async runLighthouseCI() {
+ console.log("๐ Running Lighthouse CI...");
+
+ try {
+ // Check if server is running
+ const { execSync } = require("child_process");
+ try {
+ execSync("curl -s http://localhost:3000 > /dev/null", {
+ stdio: "pipe",
+ });
+ } catch (error) {
+ console.warn(
+ "โ ๏ธ Development server not running, skipping Lighthouse CI...",
+ );
+ return;
+ }
+
+ // Run Lighthouse CI with performance focus
+ execSync("npx lhci autorun --collect.url=http://localhost:3000", {
+ stdio: "inherit",
+ cwd: path.join(__dirname, ".."),
+ });
+
+ // Parse Lighthouse results
+ await this.parseLighthouseResults();
+ } catch (error) {
+ console.warn("โ ๏ธ Lighthouse CI failed, continuing with other metrics...");
+ }
+ }
+
+ /**
+ * Parse Lighthouse CI results
+ */
+ async parseLighthouseResults() {
+ const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
+
+ if (fs.existsSync(lhciResultsPath)) {
+ const files = fs.readdirSync(lhciResultsPath);
+ const resultFile = files.find((f) => f.endsWith(".json"));
+
+ if (resultFile) {
+ const results = JSON.parse(
+ fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
+ );
+
+ if (results.lhr && results.lhr.audits) {
+ this.metrics.coreWebVitals = {
+ lcp: this.getAuditScore(
+ results.lhr.audits,
+ "largest-contentful-paint",
+ ),
+ fid: this.getAuditScore(results.lhr.audits, "max-potential-fid"),
+ cls: this.getAuditScore(
+ results.lhr.audits,
+ "cumulative-layout-shift",
+ ),
+ fcp: this.getAuditScore(
+ results.lhr.audits,
+ "first-contentful-paint",
+ ),
+ tti: this.getAuditScore(results.lhr.audits, "interactive"),
+ performance: results.lhr.categories.performance?.score * 100 || 0,
+ };
+ }
+ }
+ }
+ }
+
+ /**
+ * Get audit score from Lighthouse results
+ */
+ getAuditScore(audits, auditId) {
+ const audit = audits[auditId];
+ if (!audit) return null;
+
+ return {
+ score: audit.score * 100,
+ value: audit.numericValue,
+ displayValue: audit.displayValue,
+ };
+ }
+
+ /**
+ * Analyze bundle performance
+ */
+ async analyzeBundlePerformance() {
+ console.log("๐ฆ Analyzing bundle performance...");
+
+ const bundleStatsPath = path.join(
+ __dirname,
+ "..",
+ ".next",
+ "static",
+ "chunks",
+ );
+
+ if (fs.existsSync(bundleStatsPath)) {
+ const files = fs.readdirSync(bundleStatsPath);
+ let totalSize = 0;
+ let jsFiles = 0;
+
+ files.forEach((file) => {
+ if (file.endsWith(".js")) {
+ const filePath = path.join(bundleStatsPath, file);
+ const stats = fs.statSync(filePath);
+ totalSize += stats.size;
+ jsFiles++;
+ }
+ });
+
+ this.metrics.bundleMetrics = {
+ totalSizeKB: Math.round(totalSize / 1024),
+ totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
+ fileCount: jsFiles,
+ averageSizeKB: Math.round(totalSize / jsFiles / 1024),
+ };
+ }
+ }
+
+ /**
+ * Check performance budgets
+ */
+ checkPerformanceBudgets() {
+ const budgets = PERFORMANCE_BUDGETS.budgets;
+ const violations = [];
+
+ // Check Core Web Vitals
+ if (this.metrics.coreWebVitals.lcp) {
+ const lcpValue = this.metrics.coreWebVitals.lcp.value;
+ const lcpBudget = budgets.find((b) => b.name === "lcp")?.maxValue;
+
+ if (lcpBudget && lcpValue > lcpBudget) {
+ violations.push({
+ metric: "LCP",
+ current: lcpValue,
+ budget: lcpBudget,
+ severity: lcpValue > lcpBudget * 1.5 ? "high" : "medium",
+ });
+ }
+ }
+
+ // Check bundle size
+ if (this.metrics.bundleMetrics.totalSizeKB > 2000) {
+ violations.push({
+ metric: "Bundle Size",
+ current: this.metrics.bundleMetrics.totalSizeKB,
+ budget: 2000,
+ severity: "medium",
+ });
+ }
+
+ this.metrics.budgetViolations = violations;
+ }
+
+ /**
+ * Generate performance report
+ */
+ generatePerformanceReport() {
+ const reportPath = path.join(MONITORING_DIR, "performance-report.json");
+ fs.writeFileSync(reportPath, JSON.stringify(this.metrics, null, 2));
+
+ // Generate markdown report
+ this.generateMarkdownReport();
+ }
+
+ /**
+ * Generate markdown performance report
+ */
+ generateMarkdownReport() {
+ const reportPath = path.join(MONITORING_DIR, "performance-report.md");
+
+ let report = `# Performance Monitoring Report\n\n`;
+ report += `**Generated:** ${this.metrics.timestamp}\n\n`;
+
+ // Core Web Vitals
+ if (Object.keys(this.metrics.coreWebVitals).length > 0) {
+ report += `## Core Web Vitals\n\n`;
+ report += `| Metric | Score | Value | Status |\n`;
+ report += `|--------|-------|-------|--------|\n`;
+
+ Object.entries(this.metrics.coreWebVitals).forEach(([metric, data]) => {
+ if (data && typeof data === "object" && data.score !== undefined) {
+ const status = this.getMetricStatus(metric, data.score);
+ report += `| ${metric.toUpperCase()} | ${data.score} | ${
+ data.displayValue || "N/A"
+ } | ${status} |\n`;
+ }
+ });
+ }
+
+ // Bundle Metrics
+ if (Object.keys(this.metrics.bundleMetrics).length > 0) {
+ report += `\n## Bundle Metrics\n\n`;
+ report += `- **Total Size:** ${this.metrics.bundleMetrics.totalSizeMB}MB (${this.metrics.bundleMetrics.totalSizeKB}KB)\n`;
+ report += `- **File Count:** ${this.metrics.bundleMetrics.fileCount}\n`;
+ report += `- **Average Size:** ${this.metrics.bundleMetrics.averageSizeKB}KB per file\n`;
+ }
+
+ // Budget Violations
+ if (
+ this.metrics.budgetViolations &&
+ this.metrics.budgetViolations.length > 0
+ ) {
+ report += `\n## Budget Violations\n\n`;
+ this.metrics.budgetViolations.forEach((violation) => {
+ report += `- **${violation.metric}**: ${violation.current} (exceeds ${
+ violation.budget
+ }) - ${violation.severity.toUpperCase()}\n`;
+ });
+ }
+
+ // Recommendations
+ report += `\n## Recommendations\n\n`;
+ report += `- Monitor Core Web Vitals regularly\n`;
+ report += `- Implement code splitting for large bundles\n`;
+ report += `- Use dynamic imports for non-critical components\n`;
+ report += `- Optimize images and fonts\n`;
+ report += `- Enable compression and caching\n`;
+
+ fs.writeFileSync(reportPath, report);
+ }
+
+ /**
+ * Get status emoji for metric score
+ */
+ getMetricStatus(metric, score) {
+ if (score >= 90) return "โ
Good";
+ if (score >= 50) return "โ ๏ธ Needs Improvement";
+ return "โ Poor";
+ }
}
-// Run the performance monitor if this script is executed directly
+// Run monitoring if called directly
if (require.main === module) {
- const monitor = new PerformanceMonitorScript();
- monitor.run();
+ const monitor = new PerformanceMonitor();
+ monitor.monitorPerformance().catch(console.error);
}
-module.exports = PerformanceMonitorScript;
+module.exports = PerformanceMonitor;
diff --git a/scripts/test-performance.js b/scripts/test-performance.js
new file mode 100644
index 0000000..6edf24d
--- /dev/null
+++ b/scripts/test-performance.js
@@ -0,0 +1,349 @@
+#!/usr/bin/env node
+
+/**
+ * Comprehensive Performance Testing Script
+ * Integrates bundle analysis, performance monitoring, and Web Vitals tracking
+ */
+
+const { execSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const TEST_RESULTS_DIR = path.join(__dirname, "..", ".next", "test-results");
+
+class PerformanceTester {
+ constructor() {
+ this.results = {
+ timestamp: new Date().toISOString(),
+ bundleAnalysis: {},
+ performanceMonitoring: {},
+ webVitals: {},
+ lighthouse: {},
+ summary: {
+ passed: 0,
+ failed: 0,
+ warnings: 0,
+ total: 0,
+ },
+ };
+ }
+
+ /**
+ * Run comprehensive performance testing
+ */
+ async runTests() {
+ console.log("๐งช Starting comprehensive performance testing...");
+
+ try {
+ // Ensure test results directory exists
+ if (!fs.existsSync(TEST_RESULTS_DIR)) {
+ fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
+ }
+
+ // 1. Bundle Analysis
+ console.log("๐ Running bundle analysis...");
+ await this.runBundleAnalysis();
+
+ // 2. Performance Monitoring
+ console.log("๐ Running performance monitoring...");
+ await this.runPerformanceMonitoring();
+
+ // 3. Web Vitals Tracking
+ console.log("๐ Setting up Web Vitals tracking...");
+ await this.runWebVitalsTracking();
+
+ // 4. Lighthouse CI (if server is available)
+ console.log("๐ Running Lighthouse CI...");
+ await this.runLighthouseCI();
+
+ // 5. Generate comprehensive report
+ this.generateComprehensiveReport();
+
+ console.log("โ
Performance testing complete!");
+ console.log(`๐ Results saved to: ${TEST_RESULTS_DIR}`);
+
+ // Return exit code based on results
+ const hasFailures = this.results.summary.failed > 0;
+ if (hasFailures) {
+ console.log("โ Performance tests failed");
+ process.exit(1);
+ } else {
+ console.log("โ
All performance tests passed");
+ process.exit(0);
+ }
+ } catch (error) {
+ console.error("โ Performance testing failed:", error.message);
+ process.exit(1);
+ }
+ }
+
+ /**
+ * Run bundle analysis
+ */
+ async runBundleAnalysis() {
+ try {
+ execSync("npm run bundle:analyze", { stdio: "inherit" });
+
+ // Parse bundle analysis results
+ const bundleReportPath = path.join(
+ __dirname,
+ "..",
+ ".next",
+ "analyze",
+ "bundle-analysis.json",
+ );
+ if (fs.existsSync(bundleReportPath)) {
+ const bundleData = JSON.parse(
+ fs.readFileSync(bundleReportPath, "utf8"),
+ );
+ this.results.bundleAnalysis = bundleData;
+
+ // Check for budget violations
+ if (
+ bundleData.budgetViolations &&
+ bundleData.budgetViolations.length > 0
+ ) {
+ this.results.summary.failed += bundleData.budgetViolations.length;
+ console.log(
+ `โ ๏ธ Found ${bundleData.budgetViolations.length} budget violations`,
+ );
+ } else {
+ this.results.summary.passed += 1;
+ console.log("โ
Bundle analysis passed");
+ }
+ }
+
+ this.results.summary.total += 1;
+ } catch (error) {
+ console.error("โ Bundle analysis failed:", error.message);
+ this.results.summary.failed += 1;
+ this.results.summary.total += 1;
+ }
+ }
+
+ /**
+ * Run performance monitoring
+ */
+ async runPerformanceMonitoring() {
+ try {
+ execSync("npm run performance:monitor", { stdio: "inherit" });
+
+ // Parse performance monitoring results
+ const perfReportPath = path.join(
+ __dirname,
+ "..",
+ ".next",
+ "monitoring",
+ "performance-report.json",
+ );
+ if (fs.existsSync(perfReportPath)) {
+ const perfData = JSON.parse(fs.readFileSync(perfReportPath, "utf8"));
+ this.results.performanceMonitoring = perfData;
+
+ // Check for budget violations
+ if (perfData.budgetViolations && perfData.budgetViolations.length > 0) {
+ this.results.summary.failed += perfData.budgetViolations.length;
+ console.log(
+ `โ ๏ธ Found ${perfData.budgetViolations.length} performance violations`,
+ );
+ } else {
+ this.results.summary.passed += 1;
+ console.log("โ
Performance monitoring passed");
+ }
+ }
+
+ this.results.summary.total += 1;
+ } catch (error) {
+ console.error("โ Performance monitoring failed:", error.message);
+ this.results.summary.failed += 1;
+ this.results.summary.total += 1;
+ }
+ }
+
+ /**
+ * Run Web Vitals tracking
+ */
+ async runWebVitalsTracking() {
+ try {
+ execSync("npm run web-vitals:track", { stdio: "inherit" });
+
+ // Parse Web Vitals results
+ const vitalsReportPath = path.join(
+ __dirname,
+ "..",
+ ".next",
+ "web-vitals",
+ "report.json",
+ );
+ if (fs.existsSync(vitalsReportPath)) {
+ const vitalsData = JSON.parse(
+ fs.readFileSync(vitalsReportPath, "utf8"),
+ );
+ this.results.webVitals = vitalsData;
+ console.log("โ
Web Vitals tracking setup complete");
+ }
+
+ this.results.summary.passed += 1;
+ this.results.summary.total += 1;
+ } catch (error) {
+ console.error("โ Web Vitals tracking failed:", error.message);
+ this.results.summary.failed += 1;
+ this.results.summary.total += 1;
+ }
+ }
+
+ /**
+ * Run Lighthouse CI
+ */
+ async runLighthouseCI() {
+ try {
+ // Check if server is running
+ try {
+ execSync("curl -s http://localhost:3000 > /dev/null", {
+ stdio: "pipe",
+ });
+ } catch (error) {
+ console.warn(
+ "โ ๏ธ Development server not running, skipping Lighthouse CI...",
+ );
+ this.results.summary.warnings += 1;
+ this.results.summary.total += 1;
+ return;
+ }
+
+ execSync("npm run lhci", { stdio: "inherit" });
+
+ // Parse Lighthouse results
+ const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
+ if (fs.existsSync(lhciResultsPath)) {
+ const files = fs.readdirSync(lhciResultsPath);
+ const resultFile = files.find((f) => f.endsWith(".json"));
+
+ if (resultFile) {
+ const lhciData = JSON.parse(
+ fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
+ );
+ this.results.lighthouse = lhciData;
+ console.log("โ
Lighthouse CI completed");
+ }
+ }
+
+ this.results.summary.passed += 1;
+ this.results.summary.total += 1;
+ } catch (error) {
+ console.warn("โ ๏ธ Lighthouse CI failed:", error.message);
+ this.results.summary.warnings += 1;
+ this.results.summary.total += 1;
+ }
+ }
+
+ /**
+ * Generate comprehensive test report
+ */
+ generateComprehensiveReport() {
+ // Ensure test results directory exists
+ if (!fs.existsSync(TEST_RESULTS_DIR)) {
+ fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
+ }
+
+ const reportPath = path.join(
+ TEST_RESULTS_DIR,
+ "performance-test-report.json",
+ );
+ fs.writeFileSync(reportPath, JSON.stringify(this.results, null, 2));
+
+ // Generate markdown report
+ this.generateMarkdownReport();
+ }
+
+ /**
+ * Generate markdown test report
+ */
+ generateMarkdownReport() {
+ const reportPath = path.join(
+ TEST_RESULTS_DIR,
+ "performance-test-report.md",
+ );
+
+ let report = `# Performance Test Report\n\n`;
+ report += `**Generated:** ${this.results.timestamp}\n\n`;
+
+ // Summary
+ report += `## Test Summary\n\n`;
+ report += `- **Total Tests:** ${this.results.summary.total}\n`;
+ report += `- **Passed:** ${this.results.summary.passed} โ
\n`;
+ report += `- **Failed:** ${this.results.summary.failed} โ\n`;
+ report += `- **Warnings:** ${this.results.summary.warnings} โ ๏ธ\n\n`;
+
+ // Bundle Analysis Results
+ if (Object.keys(this.results.bundleAnalysis).length > 0) {
+ report += `## Bundle Analysis\n\n`;
+ if (
+ this.results.bundleAnalysis.budgetViolations &&
+ this.results.bundleAnalysis.budgetViolations.length > 0
+ ) {
+ report += `### Budget Violations\n\n`;
+ this.results.bundleAnalysis.budgetViolations.forEach((violation) => {
+ report += `- **${violation.file}**: ${
+ violation.currentSize
+ }KB (exceeds ${violation.maxSize}KB by ${
+ violation.overage
+ }KB) - ${violation.severity.toUpperCase()}\n`;
+ });
+ } else {
+ report += `โ
No bundle budget violations found\n\n`;
+ }
+ }
+
+ // Performance Monitoring Results
+ if (Object.keys(this.results.performanceMonitoring).length > 0) {
+ report += `## Performance Monitoring\n\n`;
+ if (
+ this.results.performanceMonitoring.budgetViolations &&
+ this.results.performanceMonitoring.budgetViolations.length > 0
+ ) {
+ report += `### Budget Violations\n\n`;
+ this.results.performanceMonitoring.budgetViolations.forEach(
+ (violation) => {
+ report += `- **${violation.metric}**: ${
+ violation.current
+ } (exceeds ${
+ violation.budget
+ }) - ${violation.severity.toUpperCase()}\n`;
+ },
+ );
+ } else {
+ report += `โ
No performance budget violations found\n\n`;
+ }
+ }
+
+ // Web Vitals Results
+ if (Object.keys(this.results.webVitals).length > 0) {
+ report += `## Web Vitals Tracking\n\n`;
+ report += `โ
Web Vitals tracking setup complete\n\n`;
+ }
+
+ // Lighthouse Results
+ if (Object.keys(this.results.lighthouse).length > 0) {
+ report += `## Lighthouse CI\n\n`;
+ report += `โ
Lighthouse CI completed successfully\n\n`;
+ }
+
+ // Recommendations
+ report += `## Recommendations\n\n`;
+ report += `- Monitor bundle sizes regularly\n`;
+ report += `- Track Core Web Vitals in production\n`;
+ report += `- Run performance tests in CI/CD pipeline\n`;
+ report += `- Set up performance budgets and alerts\n`;
+
+ fs.writeFileSync(reportPath, report);
+ }
+}
+
+// Run if called directly
+if (require.main === module) {
+ const tester = new PerformanceTester();
+ tester.runTests().catch(console.error);
+}
+
+module.exports = PerformanceTester;
diff --git a/scripts/web-vitals-tracker.js b/scripts/web-vitals-tracker.js
new file mode 100644
index 0000000..0f23302
--- /dev/null
+++ b/scripts/web-vitals-tracker.js
@@ -0,0 +1,335 @@
+#!/usr/bin/env node
+
+/**
+ * Web Vitals Tracker
+ * Real-time monitoring of Core Web Vitals in production
+ */
+
+const fs = require("fs");
+const path = require("path");
+
+const WEB_VITALS_DIR = path.join(__dirname, "..", ".next", "web-vitals");
+
+class WebVitalsTracker {
+ constructor() {
+ this.metrics = {
+ timestamp: new Date().toISOString(),
+ vitals: {
+ lcp: [],
+ fid: [],
+ cls: [],
+ fcp: [],
+ ttfb: [],
+ },
+ summary: {},
+ };
+ }
+
+ /**
+ * Track Web Vitals from client-side
+ */
+ trackWebVitals() {
+ const trackingCode = `
+// Web Vitals Tracking Script
+(function() {
+ // Import web-vitals library
+ import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+ const vitals = {};
+
+ // Track Largest Contentful Paint
+ getLCP((metric) => {
+ vitals.lcp = {
+ value: metric.value,
+ rating: metric.rating,
+ delta: metric.delta,
+ timestamp: Date.now()
+ };
+ sendVitals('lcp', vitals.lcp);
+ });
+
+ // Track First Input Delay
+ getFID((metric) => {
+ vitals.fid = {
+ value: metric.value,
+ rating: metric.rating,
+ delta: metric.delta,
+ timestamp: Date.now()
+ };
+ sendVitals('fid', vitals.fid);
+ });
+
+ // Track Cumulative Layout Shift
+ getCLS((metric) => {
+ vitals.cls = {
+ value: metric.value,
+ rating: metric.rating,
+ delta: metric.delta,
+ timestamp: Date.now()
+ };
+ sendVitals('cls', vitals.cls);
+ });
+
+ // Track First Contentful Paint
+ getFCP((metric) => {
+ vitals.fcp = {
+ value: metric.value,
+ rating: metric.rating,
+ delta: metric.delta,
+ timestamp: Date.now()
+ };
+ sendVitals('fcp', vitals.fcp);
+ });
+
+ // Track Time to First Byte
+ getTTFB((metric) => {
+ vitals.ttfb = {
+ value: metric.value,
+ rating: metric.rating,
+ delta: metric.delta,
+ timestamp: Date.now()
+ };
+ sendVitals('ttfb', vitals.ttfb);
+ });
+ });
+
+ // Send vitals to server
+ function sendVitals(metric, data) {
+ if (typeof window !== 'undefined' && window.fetch) {
+ fetch('/api/web-vitals', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ metric,
+ data,
+ url: window.location.href,
+ userAgent: navigator.userAgent,
+ timestamp: Date.now()
+ })
+ }).catch(console.error);
+ }
+ }
+})();
+`;
+
+ return trackingCode;
+ }
+
+ /**
+ * Create API endpoint for receiving Web Vitals
+ */
+ createAPIEndpoint() {
+ const apiCode = `
+// API endpoint for Web Vitals tracking
+export default function handler(req, res) {
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ try {
+ const { metric, data, url, userAgent, timestamp } = req.body;
+
+ // Store the metric data
+ const vitalsData = {
+ metric,
+ data,
+ url,
+ userAgent,
+ timestamp: new Date(timestamp).toISOString()
+ };
+
+ // In production, you would save this to a database
+ // For now, we'll log it
+ console.log('Web Vital received:', vitalsData);
+
+ res.status(200).json({ success: true });
+ } catch (error) {
+ console.error('Error processing web vital:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+}
+`;
+
+ return apiCode;
+ }
+
+ /**
+ * Generate Web Vitals dashboard
+ */
+ generateDashboard() {
+ const dashboardCode = `
+import React, { useState, useEffect } from 'react';
+
+const WebVitalsDashboard = () => {
+ const [vitals, setVitals] = useState({
+ lcp: { value: 0, rating: 'unknown' },
+ fid: { value: 0, rating: 'unknown' },
+ cls: { value: 0, rating: 'unknown' },
+ fcp: { value: 0, rating: 'unknown' },
+ ttfb: { value: 0, rating: 'unknown' }
+ });
+
+ useEffect(() => {
+ // In a real implementation, you would fetch from your database
+ // For now, we'll use localStorage for demo purposes
+ const storedVitals = localStorage.getItem('web-vitals');
+ if (storedVitals) {
+ setVitals(JSON.parse(storedVitals));
+ }
+ }, []);
+
+ const getRatingColor = (rating) => {
+ switch (rating) {
+ case 'good': return 'text-green-600';
+ case 'needs-improvement': return 'text-yellow-600';
+ case 'poor': return 'text-red-600';
+ default: return 'text-gray-600';
+ }
+ };
+
+ const getRatingIcon = (rating) => {
+ switch (rating) {
+ case 'good': return 'โ
';
+ case 'needs-improvement': return 'โ ๏ธ';
+ case 'poor': return 'โ';
+ default: return 'โ';
+ }
+ };
+
+ return (
+
+
Web Vitals Dashboard
+
+
+ {Object.entries(vitals).map(([metric, data]) => (
+
+
+
{metric.toUpperCase()}
+ {getRatingIcon(data.rating)}
+
+
+
Value: {data.value}ms
+
+ Rating: {data.rating.replace('-', ' ')}
+
+
+
+ ))}
+
+
+
+
Performance Guidelines
+
+ - โข LCP: Good < 2.5s, Needs Improvement 2.5-4s, Poor > 4s
+ - โข FID: Good < 100ms, Needs Improvement 100-300ms, Poor > 300ms
+ - โข CLS: Good < 0.1, Needs Improvement 0.1-0.25, Poor > 0.25
+ - โข FCP: Good < 1.8s, Needs Improvement 1.8-3s, Poor > 3s
+ - โข TTFB: Good < 800ms, Needs Improvement 800-1800ms, Poor > 1800ms
+
+
+
+ );
+};
+
+export default WebVitalsDashboard;
+`;
+
+ return dashboardCode;
+ }
+
+ /**
+ * Save Web Vitals data
+ */
+ saveVitalsData(metric, data) {
+ if (!fs.existsSync(WEB_VITALS_DIR)) {
+ fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
+ }
+
+ const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
+ let existingData = [];
+
+ if (fs.existsSync(filePath)) {
+ try {
+ existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ } catch (error) {
+ console.warn("Could not parse existing vitals data:", error.message);
+ }
+ }
+
+ existingData.push({
+ ...data,
+ timestamp: new Date().toISOString(),
+ });
+
+ // Keep only last 100 entries
+ if (existingData.length > 100) {
+ existingData = existingData.slice(-100);
+ }
+
+ fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
+ }
+
+ /**
+ * Generate Web Vitals report
+ */
+ generateReport() {
+ if (!fs.existsSync(WEB_VITALS_DIR)) {
+ console.log("No Web Vitals data found");
+ return;
+ }
+
+ const files = fs.readdirSync(WEB_VITALS_DIR);
+ const report = {
+ timestamp: new Date().toISOString(),
+ metrics: {},
+ };
+
+ files.forEach((file) => {
+ if (file.endsWith(".json")) {
+ const metric = file.replace(".json", "");
+ const data = JSON.parse(
+ fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"),
+ );
+
+ if (data.length > 0) {
+ const values = data
+ .map((d) => d.value)
+ .filter((v) => v !== undefined);
+ const ratings = data
+ .map((d) => d.rating)
+ .filter((r) => r !== undefined);
+
+ report.metrics[metric] = {
+ count: data.length,
+ average:
+ values.length > 0
+ ? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
+ : 0,
+ min: values.length > 0 ? Math.min(...values) : 0,
+ max: values.length > 0 ? Math.max(...values) : 0,
+ goodCount: ratings.filter((r) => r === "good").length,
+ needsImprovementCount: ratings.filter(
+ (r) => r === "needs-improvement",
+ ).length,
+ poorCount: ratings.filter((r) => r === "poor").length,
+ };
+ }
+ }
+ });
+
+ const reportPath = path.join(WEB_VITALS_DIR, "report.json");
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
+
+ console.log("๐ Web Vitals report generated:", reportPath);
+ return report;
+ }
+}
+
+// Run if called directly
+if (require.main === module) {
+ const tracker = new WebVitalsTracker();
+ tracker.generateReport();
+}
+
+module.exports = WebVitalsTracker;
diff --git a/stories/ErrorBoundary.stories.js b/stories/ErrorBoundary.stories.js
new file mode 100644
index 0000000..4290c0c
--- /dev/null
+++ b/stories/ErrorBoundary.stories.js
@@ -0,0 +1,21 @@
+import ErrorBoundary from "../app/components/ErrorBoundary";
+
+export default {
+ title: "Components/ErrorBoundary",
+ component: ErrorBoundary,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "An error boundary component that catches JavaScript errors in its child component tree. Displays a fallback UI when errors occur and logs error information for debugging.",
+ },
+ },
+ },
+ argTypes: {
+ children: {
+ control: { type: "text" },
+ description: "Child components to wrap with error boundary",
+ },
+ },
+};
diff --git a/stories/WebVitalsDashboard.stories.js b/stories/WebVitalsDashboard.stories.js
new file mode 100644
index 0000000..c4a820e
--- /dev/null
+++ b/stories/WebVitalsDashboard.stories.js
@@ -0,0 +1,39 @@
+import WebVitalsDashboard from "../app/components/WebVitalsDashboard";
+
+export default {
+ title: "Components/WebVitalsDashboard",
+ component: WebVitalsDashboard,
+ parameters: {
+ layout: "fullscreen",
+ docs: {
+ description: {
+ component:
+ "A comprehensive dashboard component that displays real-time and historical Web Vitals data. Shows Core Web Vitals metrics, performance ratings, and optimization recommendations.",
+ },
+ },
+ },
+ argTypes: {},
+};
+
+export const Default = {
+ args: {},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "The default Web Vitals dashboard showing real-time performance metrics and historical data.",
+ },
+ },
+ },
+};
+
+export const Loading = {
+ args: {},
+ parameters: {
+ docs: {
+ description: {
+ story: "The dashboard in loading state while fetching Web Vitals data.",
+ },
+ },
+ },
+};
diff --git a/tests/unit/LogoWall.test.jsx b/tests/unit/LogoWall.test.jsx
index 2c8b662..1489ada 100644
--- a/tests/unit/LogoWall.test.jsx
+++ b/tests/unit/LogoWall.test.jsx
@@ -84,7 +84,7 @@ describe("LogoWall Component", () => {
const foodNotBombsLogo = screen.getByAltText("Food Not Bombs");
expect(foodNotBombsLogo).toHaveAttribute(
"src",
- "assets/Section/Logo_FoodNotBombs.png",
+ "/assets/Section/Logo_FoodNotBombs.png",
);
expect(foodNotBombsLogo).toHaveClass("h-11", "lg:h-14", "xl:h-[70px]");
});