From e7e3bd92ca69787db36c284b91c27d6b9dd5aa70 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 14:44:41 +0300 Subject: [PATCH 1/9] Add warning logging during build for unused tags --- _TODO.md | 9 --------- src/pages/tags/index.astro | 8 ++++++++ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/_TODO.md b/_TODO.md index 308a686ec..53075a2bd 100644 --- a/_TODO.md +++ b/_TODO.md @@ -145,15 +145,6 @@ cat.structure: Rules related to the document's overall structure, like the prope cat.tables: Rules for data tables, including headers and associations. cat.text-alternatives: Rules for ensuring that text alternatives are provided for non-text content, such as images. -## Tags - -- Sitemap -- E2E all pages helper - -## Build output errors - -00:19:09 [WARN] [vite] [plugin:astro:assets:esm] context method emitFile() is not supported in serve mode. This plugin is likely not vite-compatible. - ## Use Confetti on CTA forms `canvas-confetti` diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro index 3bc7d4448..9a9cbb445 100644 --- a/src/pages/tags/index.astro +++ b/src/pages/tags/index.astro @@ -35,6 +35,14 @@ const tagsWithContent = allTags.filter(tag => { return getContentPreviewForTag(tag.data.slug, 1).length > 0 }) +const tagsWithNoContent = allTags.filter(tag => getContentPreviewForTag(tag.data.slug, 1).length === 0) + +for (const tag of tagsWithNoContent) { + console.warn( + `Warning: there were no content items for tag ${tag.data.displayName}. You might consider removing this tag if it is unused.` + ) +} + const pageDescription = 'Webstack Builders Services List' const pageTitle = 'All Tags' const path = '/tags' From 1aa1bf01db0da13098cf02dcabc0604a3bb23261 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 15:17:01 +0300 Subject: [PATCH 2/9] Add Confetti animation Lit custom web component --- .cache/pages.json | 12 +- _TODO.md | 6 - package-lock.json | 76 +++---- package.json | 2 + .../Confetti/client/__tests__/index.spec.ts | 189 ++++++++++++++++++ .../Animations/Confetti/client/index.ts | 183 +++++++++++++++++ .../Animations/Confetti/index.astro | 16 ++ 7 files changed, 416 insertions(+), 68 deletions(-) create mode 100644 src/components/Animations/Confetti/client/__tests__/index.spec.ts create mode 100644 src/components/Animations/Confetti/client/index.ts create mode 100644 src/components/Animations/Confetti/index.astro diff --git a/.cache/pages.json b/.cache/pages.json index 57343e1db..fe212b75f 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -22,16 +22,10 @@ "contact", "offline", { - "privacy": [ - "my-data" - ] + "privacy": ["my-data"] }, { - "services": [ - "consulting", - "overview", - "web-development" - ] + "services": ["consulting", "overview", "web-development"] }, { "tags": [ @@ -67,4 +61,4 @@ "typescript" ] } -] \ No newline at end of file +] diff --git a/_TODO.md b/_TODO.md index 53075a2bd..9ca677f4a 100644 --- a/_TODO.md +++ b/_TODO.md @@ -145,12 +145,6 @@ cat.structure: Rules related to the document's overall structure, like the prope cat.tables: Rules for data tables, including headers and associations. cat.text-alternatives: Rules for ensuring that text alternatives are provided for non-text content, such as images. -## Use Confetti on CTA forms - -`canvas-confetti` -`https://github.com/catdad/canvas-confetti` -`https://www.kirilv.com/canvas-confetti/` - ## Use the Page Visibility API to pause videos, image carousels, and animations Stop unnecessary processes when the user doesn't see the page or inversely to perform background actions. diff --git a/package-lock.json b/package-lock.json index 5f0bdae2d..ee04debfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.7.2", "astro-vtbot": "^2.1.9", + "canvas-confetti": "^1.9.4", "cross-env": "^10.1.0", "dotenv": "17.2.3", "email-validator": "^2.0.4", @@ -80,6 +81,7 @@ "@testing-library/preact": "3.2.4", "@testing-library/user-event": "14.6.1", "@tktco/node-actionlint": "^1.6.0", + "@types/canvas-confetti": "^1.9.0", "@types/confusing-browser-globals": "1.0.3", "@types/cross-spawn": "6.0.6", "@types/dedent": "^0.7.2", @@ -262,8 +264,7 @@ "version": "2.13.0", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@astrojs/db": { "version": "0.18.3", @@ -465,7 +466,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1028,7 +1028,6 @@ "integrity": "sha512-c7Eccw2lhFx2/+qJn3g+uIDWRuWi2A6Sz3PVvckFUEzPsP0dPUo19hlvtarwP5GzrsXn0yEPRVhpewsIaSCGaQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", @@ -1418,7 +1417,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2920,7 +2918,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3042,7 +3039,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3085,7 +3081,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4756,7 +4751,6 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", "license": "MIT", - "peer": true, "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", @@ -5190,7 +5184,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -5212,7 +5205,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -5225,7 +5217,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -5241,7 +5232,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -5629,7 +5619,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -5646,7 +5635,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -5664,7 +5652,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -7754,7 +7741,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -7906,6 +7892,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -8124,7 +8117,6 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -8348,7 +8340,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -9357,8 +9348,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/@webcomponents/template-shadowroot/-/template-shadowroot-0.2.1.tgz", "integrity": "sha512-fXL/vIUakyZL62hyvUh+EMwbVoTc0hksublmRz6ai6et8znHkJa6gtqMUZo1oc7dIz46exHSIImml9QTdknMHg==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@webgpu/types": { "version": "0.1.21", @@ -9392,7 +9382,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9781,7 +9770,6 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-5.16.5.tgz", "integrity": "sha512-QeuM4xzTR0QuXFDNlGVW0BW7rcquKFIkylaPeM4ufii0/RRiPTYtwxDYVZ3KfiMRuuc+nbLD0214kMKTvz/yvQ==", "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", @@ -10245,7 +10233,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -10616,7 +10603,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -10814,6 +10800,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/canvaskit-wasm": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", @@ -12215,8 +12211,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -12608,7 +12603,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12861,7 +12855,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -15845,7 +15838,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -16294,7 +16286,6 @@ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -16551,7 +16542,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -18109,7 +18099,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -19066,7 +19055,6 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -19111,7 +19099,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19258,7 +19245,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -19288,7 +19274,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19305,7 +19290,6 @@ "integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.9.1", "prettier": "^3.0.0", @@ -21645,7 +21629,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -21875,7 +21858,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -22197,8 +22179,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -22301,7 +22282,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -22387,7 +22367,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -22730,7 +22709,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23187,7 +23165,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -23469,7 +23446,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -23622,7 +23598,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23655,7 +23630,6 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -24407,7 +24381,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24544,7 +24517,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -24995,7 +24967,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -25158,7 +25129,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 24d523362..e8adc8747 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.7.2", "astro-vtbot": "^2.1.9", + "canvas-confetti": "^1.9.4", "cross-env": "^10.1.0", "dotenv": "17.2.3", "email-validator": "^2.0.4", @@ -126,6 +127,7 @@ "@testing-library/preact": "3.2.4", "@testing-library/user-event": "14.6.1", "@tktco/node-actionlint": "^1.6.0", + "@types/canvas-confetti": "^1.9.0", "@types/confusing-browser-globals": "1.0.3", "@types/cross-spawn": "6.0.6", "@types/dedent": "^0.7.2", diff --git a/src/components/Animations/Confetti/client/__tests__/index.spec.ts b/src/components/Animations/Confetti/client/__tests__/index.spec.ts new file mode 100644 index 000000000..bc6ab5f0d --- /dev/null +++ b/src/components/Animations/Confetti/client/__tests__/index.spec.ts @@ -0,0 +1,189 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { experimental_AstroContainer as AstroContainer } from 'astro/container' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import type { AnimationControllerConfig, AnimationControllerHandle } from '@components/scripts/store' +import { executeRender, withJsdomEnvironment } from '@test/unit/helpers/litRuntime' +import ConfettiAstro from '@components/Animations/Confetti/index.astro' +import type { ConfettiAnimationElement } from '@components/Animations/Confetti/client' + +type ConfettiModule = WebComponentModule + +type ConfettiClientModule = typeof import('@components/Animations/Confetti/client') + +let confettiElementCtor: ConfettiClientModule['ConfettiAnimationElement'] | undefined +let registerConfettiAnimationWebComponentFn: ConfettiClientModule['registerConfettiAnimationWebComponent'] | undefined +let confettiWebComponentModule: ConfettiClientModule['webComponentModule'] | undefined + +const ensureConfettiModuleLoaded = async (): Promise => { + if (confettiElementCtor && registerConfettiAnimationWebComponentFn && confettiWebComponentModule) { + return + } + + await withJsdomEnvironment(async () => { + const module = await import('@components/Animations/Confetti/client') + confettiElementCtor = module.ConfettiAnimationElement + registerConfettiAnimationWebComponentFn = module.registerConfettiAnimationWebComponent + confettiWebComponentModule = module.webComponentModule + }) +} + +const getConfettiModule = () => { + if (!confettiElementCtor || !registerConfettiAnimationWebComponentFn || !confettiWebComponentModule) { + throw new Error('Confetti module was not initialized correctly') + } + + return { + ConfettiAnimationElement: confettiElementCtor, + registerConfettiAnimationWebComponent: registerConfettiAnimationWebComponentFn, + webComponentModule: confettiWebComponentModule, + } +} + +const addScriptBreadcrumbMock = vi.hoisted(() => vi.fn()) +const handleScriptErrorMock = vi.hoisted(() => vi.fn()) + +const createAnimationControllerMock = vi.hoisted(() => + vi.fn((_config: AnimationControllerConfig): AnimationControllerHandle => ({ + requestPlay: vi.fn(), + requestPause: vi.fn(), + clearUserPreference: vi.fn(), + destroy: vi.fn(), + })) +) + +const confettiInstanceMock = vi.hoisted(() => vi.fn()) +const confettiCreateMock = vi.hoisted(() => vi.fn(() => confettiInstanceMock)) + +vi.mock('@components/scripts/errors', () => ({ + addScriptBreadcrumb: addScriptBreadcrumbMock, +})) + +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: handleScriptErrorMock, +})) + +vi.mock('@components/scripts/store', () => ({ + createAnimationController: createAnimationControllerMock, +})) + +vi.mock('canvas-confetti', () => ({ + create: confettiCreateMock, +})) + +let container: AstroContainer + +beforeAll(async () => { + await ensureConfettiModuleLoaded() +}) + +beforeEach(async () => { + container = await AstroContainer.create() + vi.clearAllMocks() +}) + +describe('Confetti web component module', () => { + it('exposes metadata required by the loader', () => { + const { webComponentModule: module, ConfettiAnimationElement: ctor, registerConfettiAnimationWebComponent } = + getConfettiModule() + + expect(module.registeredName).toBe('confetti-animation') + expect(module.componentCtor).toBe(ctor) + expect(module.registerWebComponent).toBe(registerConfettiAnimationWebComponent) + }) + + it('registers the custom element when window is available', async () => { + const { registerConfettiAnimationWebComponent, ConfettiAnimationElement: ctor } = getConfettiModule() + + await withJsdomEnvironment(async ({ window }) => { + const tagName = 'confetti-animation' + registerConfettiAnimationWebComponent(tagName) + expect(window.customElements.get(tagName)).toBe(ctor) + }) + }) +}) + +describe('ConfettiAnimationElement', () => { + const renderConfetti = async ( + assertion: (_context: { element: ConfettiAnimationElement }) => Promise | void, + ): Promise => { + await executeRender({ + container, + component: ConfettiAstro, + moduleSpecifier: '@components/Animations/Confetti/client/index', + args: {}, + waitForReady: async (element) => { + await element.updateComplete + }, + assert: async ({ element, module, renderResult }) => { + expect(renderResult).toContain(`<${module.registeredName}`) + await assertion({ element }) + }, + }) + } + + it('creates an animation controller on initialize', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + + expect(createAnimationControllerMock).toHaveBeenCalledWith( + expect.objectContaining({ + animationId: 'confetti-animation', + debugLabel: 'ConfettiAnimationElement', + onPause: expect.any(Function), + onPlay: expect.any(Function), + }), + ) + }) + }) + + it('creates a canvas-confetti instance and fires when allowed', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + element.fire() + + expect(confettiCreateMock).toHaveBeenCalledTimes(1) + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + + const args = confettiInstanceMock.mock.calls[0]?.[0] + expect(args).toEqual( + expect.objectContaining({ + particleCount: expect.any(Number), + spread: expect.any(Number), + startVelocity: expect.any(Number), + }), + ) + }) + }) + + it('skips firing when controller pauses animations', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + + const controllerArgs = createAnimationControllerMock.mock.calls[0]?.[0] + controllerArgs?.onPause() + + element.fire() + expect(confettiInstanceMock).not.toHaveBeenCalled() + + controllerArgs?.onPlay() + + element.fire() + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + }) + }) + + it('fires in response to a confetti:fire event', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + + element.dispatchEvent( + new CustomEvent('confetti:fire', { + detail: { particleCount: 12 }, + }), + ) + + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + expect(confettiInstanceMock).toHaveBeenCalledWith(expect.objectContaining({ particleCount: 12 })) + }) + }) +}) diff --git a/src/components/Animations/Confetti/client/index.ts b/src/components/Animations/Confetti/client/index.ts new file mode 100644 index 000000000..0ab22b77f --- /dev/null +++ b/src/components/Animations/Confetti/client/index.ts @@ -0,0 +1,183 @@ +import { LitElement } from 'lit' +import { create as createConfetti } from 'canvas-confetti' +import { addScriptBreadcrumb } from '@components/scripts/errors' +import { handleScriptError } from '@components/scripts/errors/handler' +import { + createAnimationController, + type AnimationControllerHandle, +} from '@components/scripts/store' +import { defineCustomElement } from '@components/scripts/utils' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' + +const SCRIPT_NAME = 'ConfettiAnimationElement' +const COMPONENT_TAG_NAME = 'confetti-animation' +const CANVAS_SELECTOR = '[data-confetti-canvas]' + +export type ConfettiOrigin = { + x: number + y: number +} + +export type ConfettiFireOptions = { + origin?: ConfettiOrigin + particleCount?: number + spread?: number + startVelocity?: number + scalar?: number +} + +type ConfettiInstance = ReturnType + +export class ConfettiAnimationElement extends LitElement { + private initialized = false + private animationController: AnimationControllerHandle | undefined + private canAnimate = true + private confettiInstance: ConfettiInstance | undefined + + private readonly domReadyHandler = () => { + document.removeEventListener('DOMContentLoaded', this.domReadyHandler) + this.initialize() + } + + override createRenderRoot() { + return this + } + + override connectedCallback(): void { + super.connectedCallback() + if (typeof document === 'undefined') return + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', this.domReadyHandler) + return + } + + this.initialize() + } + + override disconnectedCallback(): void { + if (typeof document !== 'undefined') { + document.removeEventListener('DOMContentLoaded', this.domReadyHandler) + this.teardown() + } + + super.disconnectedCallback() + } + + initialize(): void { + if (this.initialized) return + + const context = { scriptName: SCRIPT_NAME, operation: 'initialize' } + addScriptBreadcrumb(context) + + try { + this.registerFireListener() + this.ensureConfettiInstance() + + this.animationController = createAnimationController({ + animationId: 'confetti-animation', + debugLabel: SCRIPT_NAME, + // Reduced motion and global pause should only be respected via the store. + defaultState: 'playing', + onPause: () => { + this.canAnimate = false + }, + onPlay: () => { + this.canAnimate = true + }, + }) + + this.initialized = true + } catch (error) { + handleScriptError(error, context) + } + } + + /** + * Trigger a one-shot confetti burst. + * + * If animations are globally paused (including reduced motion), this is a no-op. + */ + fire(options: ConfettiFireOptions = {}): void { + const context = { scriptName: SCRIPT_NAME, operation: 'fire' } + addScriptBreadcrumb(context) + + try { + if (!this.canAnimate) return + this.ensureConfettiInstance() + if (!this.confettiInstance) return + + const { + origin, + particleCount = 140, + spread = 70, + startVelocity = 45, + scalar = 1, + } = options + + void this.confettiInstance({ + particleCount, + spread, + startVelocity, + scalar, + origin, + }) + } catch (error) { + handleScriptError(error, context) + } + } + + private registerFireListener(): void { + this.addEventListener('confetti:fire', (event) => { + const detail = (event as CustomEvent).detail + this.fire(detail ?? {}) + }) + } + + private ensureConfettiInstance(): void { + if (typeof window === 'undefined') return + if (this.confettiInstance) return + + const canvas = this.querySelector(CANVAS_SELECTOR) as HTMLCanvasElement | null + if (!canvas) return + + // Prefer a per-canvas instance so callers can place the canvas where they want. + this.confettiInstance = createConfetti(canvas, { + resize: true, + useWorker: true, + }) + } + + private teardown(): void { + if (!this.initialized) return + + const context = { scriptName: SCRIPT_NAME, operation: 'teardown' } + addScriptBreadcrumb(context) + + try { + this.animationController?.destroy() + this.animationController = undefined + this.confettiInstance = undefined + this.canAnimate = true + this.initialized = false + } catch (error) { + handleScriptError(error, context) + } + } +} + +export const registerWebComponent = async (tagName = COMPONENT_TAG_NAME) => { + if (typeof window === 'undefined') { + return + } + + defineCustomElement(tagName, ConfettiAnimationElement) +} + +export const registerConfettiAnimationWebComponent = registerWebComponent + +export const webComponentModule: WebComponentModule = { + registeredName: COMPONENT_TAG_NAME, + componentCtor: ConfettiAnimationElement, + registerWebComponent, +} diff --git a/src/components/Animations/Confetti/index.astro b/src/components/Animations/Confetti/index.astro new file mode 100644 index 000000000..b871e3832 --- /dev/null +++ b/src/components/Animations/Confetti/index.astro @@ -0,0 +1,16 @@ +--- +/** + * Short-lived confetti animation. + * + * The client web component respects global animation lifecycle state via the store. + */ +--- + + + + + + From c7d48f964b44a87fcab2244ba855b8c2680e98e1 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 16:00:08 +0300 Subject: [PATCH 3/9] Props tweaks to Confetti animation component --- .../Confetti/client/__tests__/index.spec.ts | 65 +++++++++++++- .../Animations/Confetti/client/index.ts | 88 ++++++++++++++++++- 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/components/Animations/Confetti/client/__tests__/index.spec.ts b/src/components/Animations/Confetti/client/__tests__/index.spec.ts index bc6ab5f0d..0c93cb2d6 100644 --- a/src/components/Animations/Confetti/client/__tests__/index.spec.ts +++ b/src/components/Animations/Confetti/client/__tests__/index.spec.ts @@ -104,7 +104,7 @@ describe('Confetti web component module', () => { describe('ConfettiAnimationElement', () => { const renderConfetti = async ( - assertion: (_context: { element: ConfettiAnimationElement }) => Promise | void, + assertion: (_context: { element: ConfettiAnimationElement; window: Window }) => Promise | void, ): Promise => { await executeRender({ container, @@ -114,9 +114,9 @@ describe('ConfettiAnimationElement', () => { waitForReady: async (element) => { await element.updateComplete }, - assert: async ({ element, module, renderResult }) => { + assert: async ({ element, module, renderResult, window }) => { expect(renderResult).toContain(`<${module.registeredName}`) - await assertion({ element }) + await assertion({ element, window: window as unknown as Window }) }, }) } @@ -186,4 +186,63 @@ describe('ConfettiAnimationElement', () => { expect(confettiInstanceMock).toHaveBeenCalledWith(expect.objectContaining({ particleCount: 12 })) }) }) + + it('defaults the origin to the bottom-center of the event target element', async () => { + await renderConfetti(async ({ element, window }) => { + element.initialize() + + Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 500, configurable: true }) + + const button = window.document.createElement('button') + button.setAttribute('type', 'button') + element.appendChild(button) + + button.getBoundingClientRect = () => + ({ + x: 100, + y: 50, + left: 100, + top: 50, + width: 200, + height: 40, + right: 300, + bottom: 90, + toJSON: () => ({}), + }) as DOMRect + + button.dispatchEvent( + new CustomEvent('confetti:fire', { + bubbles: true, + detail: { particleCount: 12 }, + }), + ) + + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + + const callArgs = confettiInstanceMock.mock.calls[0]?.[0] + expect(callArgs).toEqual(expect.objectContaining({ particleCount: 12 })) + expect(callArgs?.origin?.x).toBeCloseTo(0.2, 5) + expect(callArgs?.origin?.y).toBeCloseTo(0.18, 5) + }) + }) + + it('passes colors and shapes through to canvas-confetti', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + + element.fire({ + colors: ['#bada55', '#ff0000'], + shapes: ['star'], + }) + + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + expect(confettiInstanceMock).toHaveBeenCalledWith( + expect.objectContaining({ + colors: ['#bada55', '#ff0000'], + shapes: ['star'], + }), + ) + }) + }) }) diff --git a/src/components/Animations/Confetti/client/index.ts b/src/components/Animations/Confetti/client/index.ts index 0ab22b77f..545dc0428 100644 --- a/src/components/Animations/Confetti/client/index.ts +++ b/src/components/Animations/Confetti/client/index.ts @@ -1,5 +1,6 @@ import { LitElement } from 'lit' import { create as createConfetti } from 'canvas-confetti' +import type { Shape as ConfettiShape } from 'canvas-confetti' import { addScriptBreadcrumb } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { @@ -14,16 +15,59 @@ const COMPONENT_TAG_NAME = 'confetti-animation' const CANVAS_SELECTOR = '[data-confetti-canvas]' export type ConfettiOrigin = { + /** + * The x position on the page, with 0 being the left edge and 1 being the right edge (default: 0.5) + */ x: number + /** + * The y position on the page, with 0 being the top edge and 1 being the bottom edge (default: 0.5) + */ y: number } export type ConfettiFireOptions = { + /** + * Where to start firing confetti from + */ origin?: ConfettiOrigin + + /** + * The number of confetti to launch + */ particleCount?: number + + /** + * How far off center the confetti can go, in degrees. 70 means the confetti + * will launch at the defined angle plus or minus 35 degrees (default: 70). + */ spread?: number + + /** + * How fast the confetti will start going, in pixels (default: 45) + */ startVelocity?: number + + /** + * Scale factor for each confetti particle. Use decimals to make the confetti + * smaller. (default: 1) + */ scalar?: number + + /** + * An array of color strings in HEX format, e.g. `#bada55`. + * + * Default: `canvas-confetti`'s built-in colors. + */ + colors?: string[] + + /** + * An array of shapes for the confetti. + * Built-ins: `square`, `circle`, `star`. + * Custom shapes can be created via `shapeFromPath` / `shapeFromText` from `canvas-confetti`. + * + * Default: `['square', 'circle']`. + */ + shapes?: ConfettiShape[] } type ConfettiInstance = ReturnType @@ -34,6 +78,28 @@ export class ConfettiAnimationElement extends LitElement { private canAnimate = true private confettiInstance: ConfettiInstance | undefined + private clamp01(value: number): number { + return Math.max(0, Math.min(1, value)) + } + + private getOriginFromElement(element: Element): ConfettiOrigin | undefined { + if (typeof window === 'undefined') return + + const rect = element.getBoundingClientRect() + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + + if (!viewportWidth || !viewportHeight) return + + const originX = (rect.left + rect.width / 2) / viewportWidth + const originY = (rect.top + rect.height) / viewportHeight + + return { + x: this.clamp01(originX), + y: this.clamp01(originY), + } + } + private readonly domReadyHandler = () => { document.removeEventListener('DOMContentLoaded', this.domReadyHandler) this.initialize() @@ -109,10 +175,12 @@ export class ConfettiAnimationElement extends LitElement { const { origin, - particleCount = 140, + particleCount = 50, spread = 70, startVelocity = 45, scalar = 1, + colors, + shapes = ['square', 'circle'], } = options void this.confettiInstance({ @@ -121,6 +189,8 @@ export class ConfettiAnimationElement extends LitElement { startVelocity, scalar, origin, + colors, + shapes, }) } catch (error) { handleScriptError(error, context) @@ -130,6 +200,22 @@ export class ConfettiAnimationElement extends LitElement { private registerFireListener(): void { this.addEventListener('confetti:fire', (event) => { const detail = (event as CustomEvent).detail + + const target = event.target + const elementTarget = target instanceof Element ? target : undefined + const originFromTarget = elementTarget ? this.getOriginFromElement(elementTarget) : undefined + + const originToUse = detail?.origin ?? originFromTarget + + if (originToUse) { + this.fire({ + ...(detail ?? {}), + origin: originToUse, + }) + + return + } + this.fire(detail ?? {}) }) } From 32b62874acd403ce9c7bd5546745b73481415792 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 16:13:52 +0300 Subject: [PATCH 4/9] Update Download Form to use Confetti animation component --- .../Animations/Confetti/index.astro | 3 ++- .../Download/client/__tests__/index.spec.ts | 22 +++++++++++++++++++ src/components/Forms/Download/client/index.ts | 8 +++++++ src/components/Forms/Download/index.astro | 22 +++++++++++-------- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/components/Animations/Confetti/index.astro b/src/components/Animations/Confetti/index.astro index b871e3832..c61ce0f95 100644 --- a/src/components/Animations/Confetti/index.astro +++ b/src/components/Animations/Confetti/index.astro @@ -6,8 +6,9 @@ */ --- - + +