diff --git a/index.html b/index.html index 4b11e71..628c52e 100644 --- a/index.html +++ b/index.html @@ -3,13 +3,13 @@ - Grain | Dynamic Gradients & Noise + Grain | Neural Gradient Explorer - + @@ -35,10 +35,10 @@ - + diff --git a/package.json b/package.json index c54dee8..9eeb379 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,18 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/roboto-mono": "^5.2.8", + "@fontsource/space-grotesk": "^5.2.10", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.17", "cssnano": "^7.1.0", + "framer-motion": "^12.23.24", + "html2canvas": "^1.4.1", "lucide-preact": "^0.468.0", "preact": "^10.27.2", "preact-iso": "^2.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9e3c23..4b91911 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,42 @@ importers: .: dependencies: + '@fontsource/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/roboto-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/space-grotesk': + specifier: ^5.2.10 + version: 5.2.10 + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-separator': + specifier: ^1.1.8 + version: 1.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tailwindcss/vite': specifier: ^4.1.17 version: 4.1.17(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(yaml@2.8.1)) cssnano: specifier: ^7.1.0 version: 7.1.0(postcss@8.5.6) + framer-motion: + specifier: ^12.23.24 + version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 lucide-preact: specifier: ^0.468.0 version: 0.468.0(preact@10.27.2) @@ -386,6 +416,30 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@fontsource/inter@5.2.8': + resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==} + + '@fontsource/roboto-mono@5.2.8': + resolution: {integrity: sha512-M0XPRcvkOKiTPooXwX7yJSNpuad3CPcQwsADBfnopz3apBs+VKI47pc42mlvAxJjNTRauu5rndSxHc8F52bR7Q==} + + '@fontsource/space-grotesk@5.2.10': + resolution: {integrity: sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -545,6 +599,345 @@ packages: preact: ^10.4.0 vite: '>=2.0.0' + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rollup/pluginutils@4.2.1': resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} engines: {node: '>= 8.0.0'} @@ -842,6 +1235,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} @@ -900,6 +1297,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -983,6 +1384,9 @@ packages: peerDependencies: postcss: ^8.0.9 + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -1086,6 +1490,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1309,6 +1716,20 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + framer-motion@12.23.24: + resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1336,6 +1757,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1406,6 +1831,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1730,6 +2159,12 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2047,12 +2482,51 @@ packages: random-js@2.1.0: resolution: {integrity: sha512-CRUyWmnzmZBA7RZSVGq0xMqmgCyPPxbiKNLFA5ud7KenojVX2s7Gv+V7eB52beKTPGxWRnVZ7D/tCIgYJJ8vNQ==} + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2119,6 +2593,9 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2257,6 +2734,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2279,6 +2759,9 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2323,9 +2806,32 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + vite-prerender-plugin@0.5.11: resolution: {integrity: sha512-xWOhb8Ef2zoJIiinYVunIf3omRfUbEXcPEvrkQcrDpJ2yjDokxhvQ26eSJbkthRhymntWx6816jpATrJphh+ug==} peerDependencies: @@ -2746,6 +3252,29 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@floating-ui/utils@0.2.10': {} + + '@fontsource/inter@5.2.8': {} + + '@fontsource/roboto-mono@5.2.8': {} + + '@fontsource/space-grotesk@5.2.10': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2895,6 +3424,245 @@ snapshots: transitivePeerDependencies: - supports-color + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-collection@1.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-context': 1.1.2(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-compose-refs@1.1.2(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@radix-ui/react-context@1.1.2(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@radix-ui/react-dialog@1.1.15(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-context': 1.1.2(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(react@19.2.0) + + '@radix-ui/react-direction@1.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@radix-ui/react-dismissable-layer@1.1.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-focus-guards@1.1.3(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@radix-ui/react-focus-scope@1.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-id@1.1.1(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + react: 19.2.0 + + '@radix-ui/react-popper@1.2.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-arrow': 1.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-context': 1.1.2(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + '@radix-ui/react-use-rect': 1.1.1(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(react@19.2.0) + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-portal@1.1.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-presence@1.1.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-primitive@2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-primitive@2.1.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.4(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-select@2.2.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-context': 1.1.2(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(react@19.2.0) + + '@radix-ui/react-separator@1.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-slider@1.3.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-context': 1.1.2(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-slot@1.2.3(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + react: 19.2.0 + + '@radix-ui/react-slot@1.2.4(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + react: 19.2.0 + + '@radix-ui/react-tooltip@1.2.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(react@19.2.0) + '@radix-ui/react-context': 1.1.2(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/react-use-callback-ref@1.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@radix-ui/react-use-controllable-state@1.2.2(react@19.2.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + react: 19.2.0 + + '@radix-ui/react-use-effect-event@0.0.2(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + react: 19.2.0 + + '@radix-ui/react-use-escape-keydown@1.1.1(react@19.2.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(react@19.2.0) + react: 19.2.0 + + '@radix-ui/react-use-layout-effect@1.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@radix-ui/react-use-previous@1.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@radix-ui/react-use-rect@1.1.1(react@19.2.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + + '@radix-ui/react-use-size@1.1.1(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(react@19.2.0) + react: 19.2.0 + + '@radix-ui/react-visually-hidden@1.2.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@radix-ui/rect@1.1.1': {} + '@rollup/pluginutils@4.2.1': dependencies: estree-walker: 2.0.2 @@ -3151,6 +3919,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.1.3: dependencies: deep-equal: 2.2.3 @@ -3232,6 +4004,8 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: {} + boolbase@1.0.0: {} brace-expansion@1.1.12: @@ -3321,6 +4095,10 @@ snapshots: dependencies: postcss: 8.5.6 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -3461,6 +4239,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3827,6 +4607,15 @@ snapshots: dependencies: is-callable: 1.2.7 + framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + fsevents@2.3.3: optional: true @@ -3860,6 +4649,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -3920,6 +4711,11 @@ snapshots: he@1.2.0: {} + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + ignore@5.3.2: {} immutable@5.1.4: @@ -4211,6 +5007,12 @@ snapshots: dependencies: brace-expansion: 1.1.12 + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -4514,10 +5316,38 @@ snapshots: random-js@2.1.0: {} + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + react-is@16.13.1: {} react-is@17.0.2: {} + react-remove-scroll-bar@2.3.8(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(react@19.2.0) + tslib: 2.8.1 + + react-remove-scroll@2.7.1(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(react@19.2.0) + react-style-singleton: 2.2.3(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(react@19.2.0) + use-sidecar: 1.1.3(react@19.2.0) + + react-style-singleton@2.2.3(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + + react@19.2.0: {} + readdirp@4.1.2: optional: true @@ -4621,6 +5451,8 @@ snapshots: sax@1.4.1: {} + scheduler@0.27.0: {} + semver@6.3.1: {} semver@7.7.2: {} @@ -4794,6 +5626,10 @@ snapshots: tapable@2.3.0: {} + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -4812,6 +5648,8 @@ snapshots: totalist@3.0.1: {} + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -4872,8 +5710,23 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + + use-sidecar@1.1.3(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + vite-prerender-plugin@0.5.11(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(yaml@2.8.1)): dependencies: kolorist: 1.8.0 diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx new file mode 100644 index 0000000..21796b8 --- /dev/null +++ b/src/components/ExportModal.tsx @@ -0,0 +1,116 @@ +import { useState } from "preact/hooks"; +import * as Dialog from "@radix-ui/react-dialog"; +import { X, Download, Copy, Check } from "lucide-preact"; +import { GlassButton } from "./GlassButton"; +import { exportAsPNG, generateCSS, copyCSSToClipboard, downloadCSS, type ExportData } from "../utils/exportGradient"; + +interface ExportModalProps { + open: boolean; + onClose: () => void; + exportData: ExportData; +} + +export function ExportModal({ open, onClose, exportData }: ExportModalProps) { + const [copied, setCopied] = useState(false); + const [exporting, setExporting] = useState(false); + + const cssCode = generateCSS(exportData); + + const handleCopyCSS = async () => { + try { + await copyCSSToClipboard(cssCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy:", error); + } + }; + + const handleDownloadCSS = () => { + downloadCSS(cssCode); + }; + + const handleExportPNG = async () => { + setExporting(true); + try { + await exportAsPNG("gradient-container"); + } catch (error) { + console.error("Failed to export PNG:", error); + } finally { + setExporting(false); + } + }; + + return ( + !isOpen && onClose()}> + + + +
+ {/* Header */} +
+ + Export Gradient + + + + +
+ + {/* PNG Export Section */} +
+

+ Export as Image +

+ + + {exporting ? "Exporting..." : "Download PNG"} + +
+ + {/* CSS Export Section */} +
+

+ Export as CSS +

+ + {/* CSS Code Preview */} +
+
+                  {cssCode}
+                
+
+ + {/* CSS Actions */} +
+ + {copied ? : } + {copied ? "Copied!" : "Copy CSS"} + + + + Download CSS + +
+
+
+
+
+
+ ); +} diff --git a/src/components/GlassButton.tsx b/src/components/GlassButton.tsx new file mode 100644 index 0000000..af964fc --- /dev/null +++ b/src/components/GlassButton.tsx @@ -0,0 +1,58 @@ +import { type ComponentChildren } from "preact"; +import { motion } from "framer-motion"; +import * as Tooltip from "@radix-ui/react-tooltip"; + +interface GlassButtonProps { + children: ComponentChildren; + onClick?: () => void; + className?: string; + disabled?: boolean; + tooltip?: string; + "aria-label"?: string; +} + +export function GlassButton({ + children, + onClick, + className = "", + disabled = false, + tooltip, + "aria-label": ariaLabel +}: GlassButtonProps) { + const button = ( + + {children} + + ); + + if (tooltip) { + return ( + + + + {button} + + + + {tooltip} + + + + + + ); + } + + return button; +} diff --git a/src/components/GlassPanel.tsx b/src/components/GlassPanel.tsx new file mode 100644 index 0000000..8bda69d --- /dev/null +++ b/src/components/GlassPanel.tsx @@ -0,0 +1,32 @@ +import { type ComponentChildren } from "preact"; +import { motion } from "framer-motion"; + +interface GlassPanelProps { + children: ComponentChildren; + className?: string; + animate?: boolean; +} + +export function GlassPanel({ children, className = "", animate = true }: GlassPanelProps) { + const Component = animate ? motion.div : "div"; + + const animationProps = animate ? { + initial: { opacity: 0, x: -20 }, + animate: { opacity: 1, x: 0 }, + transition: { + type: "spring" as const, + stiffness: 200, + damping: 25, + duration: 0.4 + } + } : {}; + + return ( + + {children} + + ); +} diff --git a/src/components/GradientControls.tsx b/src/components/GradientControls.tsx new file mode 100644 index 0000000..43f9be3 --- /dev/null +++ b/src/components/GradientControls.tsx @@ -0,0 +1,97 @@ +import { RefreshCw, Download } from "lucide-preact"; +import { GlassButton } from "./GlassButton"; +import { SliderControl } from "./SliderControl"; +import { PaletteSelector } from "./PaletteSelector"; +import * as Separator from "@radix-ui/react-separator"; + +interface GradientControlsProps { + paletteId: string; + onPaletteChange: (paletteId: string) => void; + gradientCount: number; + onGradientCountChange: (count: number) => void; + noiseIntensity: number; + onNoiseIntensityChange: (intensity: number) => void; + onRegenerate: () => void; + onExport: () => void; +} + +export function GradientControls({ + paletteId, + onPaletteChange, + gradientCount, + onGradientCountChange, + noiseIntensity, + onNoiseIntensityChange, + onRegenerate, + onExport, +}: GradientControlsProps) { + return ( +
+ {/* Header */} +
+

+ Grain +

+
+ + + + {/* Controls */} +
+ {/* Palette Selector */} + + + + + {/* Gradient Count Slider */} + + + {/* Noise Intensity Slider */} + `${Math.round(v * 100)}%`} + /> + + + + {/* Action Buttons */} +
+ + + Regenerate + + + + + Export + +
+
+
+ ); +} diff --git a/src/components/PaletteSelector.tsx b/src/components/PaletteSelector.tsx new file mode 100644 index 0000000..ae69b98 --- /dev/null +++ b/src/components/PaletteSelector.tsx @@ -0,0 +1,58 @@ +import { COLOR_PALETTES, type ColorPalette } from "../utils/palettes"; +import { motion } from "framer-motion"; + +interface PaletteSelectorProps { + selectedPaletteId: string; + onSelect: (paletteId: string) => void; +} + +export function PaletteSelector({ selectedPaletteId, onSelect }: PaletteSelectorProps) { + return ( +
+ +
+ {COLOR_PALETTES.map((palette) => ( + onSelect(palette.id)} + /> + ))} +
+
+ ); +} + +interface PaletteSwatchProps { + palette: ColorPalette; + isSelected: boolean; + onClick: () => void; +} + +function PaletteSwatch({ palette, isSelected, onClick }: PaletteSwatchProps) { + return ( + +
+ {palette.colors.slice(0, 6).map((color, index) => ( +
+ ))} +
+ + {palette.name} + + + ); +} diff --git a/src/components/Post.tsx b/src/components/Post.tsx deleted file mode 100644 index 9cf79e9..0000000 --- a/src/components/Post.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Sparkles, ShieldAlert } from "lucide-preact"; - -const Post = () => { - return ( - <> -
-
-

- Grain -

- - Created by{" "} - - Ryan Walters - - - - - -
-
-

- A small experiment on creating beautiful, dynamic backgrounds with - colorful gradients & film grain. Built in React & Vite{" "} - with SVGs and layers of Radial Gradients. -

-

- This app was inspired by the gradients used{" "} - - certain popular instagram post - {" "} - with beautiful gradients and a slight film grain applied. I wanted - to create something similar, but in a website form. -

-

- By using a SVG with a{" "} - <feTurbulence> filter inside, - stacked upon several radial-gradient{" "} - background images, the same effect can be created. Since SVGs do not - naturally repeat internally, the SVG itself must be generated in - such a way that the noise always displays the same way. -

-

- React comes in handy here, allowing composition of an SVG, and then - conversion to a base64 encoded string. - As a base64 image, it can be fed into - the background CSS property, allowing - dynamic SVG generation. -

-
- -
- In Progress - -
-
-
-
-
- - ); -}; -export default Post; diff --git a/src/components/SliderControl.tsx b/src/components/SliderControl.tsx new file mode 100644 index 0000000..f4db47e --- /dev/null +++ b/src/components/SliderControl.tsx @@ -0,0 +1,50 @@ +import * as Slider from "@radix-ui/react-slider"; + +interface SliderControlProps { + label: string; + value: number; + onChange: (value: number) => void; + min: number; + max: number; + step?: number; + formatValue?: (value: number) => string; +} + +export function SliderControl({ + label, + value, + onChange, + min, + max, + step = 1, + formatValue = (v) => v.toString() +}: SliderControlProps) { + return ( +
+
+ + + {formatValue(value)} + +
+ onChange(newValue)} + min={min} + max={max} + step={step} + > + + + + + +
+ ); +} diff --git a/src/index.css b/src/index.css index e2c99ec..1b78c3c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,14 +1,16 @@ -@import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&family=Raleway:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono&display=swap"); +@import "@fontsource/space-grotesk/400.css"; +@import "@fontsource/space-grotesk/600.css"; +@import "@fontsource/space-grotesk/700.css"; +@import "@fontsource/inter/400.css"; +@import "@fontsource/inter/500.css"; +@import "@fontsource/inter/600.css"; +@import "@fontsource/roboto-mono/400.css"; @import "tailwindcss"; @theme { - --font-family-opensans: "Open Sans", sans-serif; + --font-family-space-grotesk: "Space Grotesk", sans-serif; --font-family-inter: "Inter", sans-serif; --font-family-mono: "Roboto Mono", monospace; - --font-family-raleway: "Raleway", sans-serif; - --font-family-roboto: "Roboto"; --box-shadow-inner-md: inset 1px 4px 6px 0 rgb(0 0 0 / 0.1); --box-shadow-inner-md-2: inset 2px 2px 6px 0 rgb(0 0 0 / 0.15); @@ -60,3 +62,47 @@ body { pre.inline { @apply text-zinc-900; } + +/* Glassmorphism utilities */ +.glass-panel { + background: rgba(255, 255, 255, 0.25); + backdrop-filter: blur(50px) saturate(100%); + /* -webkit-backdrop-filter: blur(10px) saturate(80%); */ + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.glass-button { + background: rgba(255, 255, 255, 0.25); + backdrop-filter: blur(10px) saturate(60%); + -webkit-backdrop-filter: blur(10px) saturate(60%); + border: 1px solid rgba(255, 255, 255, 0.4); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transition: all 0.2s ease; +} + +.glass-button:hover { + background: rgba(255, 255, 255, 0.55); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); + transform: translateY(-1px); +} + +.glass-button:active { + transform: translateY(0); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); +} + +.glass-input { + background: rgba(255, 255, 255, 0.35); + backdrop-filter: blur(8px) saturate(60%); + -webkit-backdrop-filter: blur(8px) saturate(60%); + border: 1px solid rgba(255, 255, 255, 0.25); +} + +.glass-card { + background: rgba(255, 255, 255, 0.35); + backdrop-filter: blur(15px) saturate(50%); + -webkit-backdrop-filter: blur(15px) saturate(50%); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08); +} diff --git a/src/index.tsx b/src/index.tsx index 6812b26..f351a19 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,71 +1,98 @@ import { hydrate, prerender as ssr } from "preact-iso"; +import { useMemo, useState, useEffect } from "preact/hooks"; import "./index.css"; import { useViewportSize } from "./utils/useViewportSize"; -import { useBooleanToggle } from "./utils/useBooleanToggle"; import useBackground from "./utils/useBackground"; -import Post from "./components/Post"; -import { RefreshCw, Eye, EyeOff } from "lucide-preact"; -import { useMemo } from "preact/hooks"; +import { GlassPanel } from "./components/GlassPanel"; +import { GradientControls } from "./components/GradientControls"; +import { ExportModal } from "./components/ExportModal"; +import { getPaletteById } from "./utils/palettes"; +import type { ExportData } from "./utils/exportGradient"; export function App() { const { width, height } = useViewportSize(); + + // Gradient state + const [paletteId, setPaletteId] = useState("classic"); + const [gradientCount, setGradientCount] = useState(5); + const [noiseIntensity, setNoiseIntensity] = useState(0.9); + const [exportModalOpen, setExportModalOpen] = useState(false); + + const palette = getPaletteById(paletteId); + const { svg, backgrounds, regenerate } = useBackground({ width, height, ratio: 0.4, + paletteColors: palette.colors, + gradientCount, + noiseIntensity, }); - const [postHidden, toggleHidden] = useBooleanToggle(false); - const [iconSpinning, toggleIconSpinning] = useBooleanToggle(false); - const style = useMemo(() => { + const gradientStyle = useMemo(() => { return { background: [`url("${svg}")`, ...backgrounds].join(", "), }; }, [svg, backgrounds]); + const exportData: ExportData = useMemo(() => ({ + backgrounds, + svg, + noiseIntensity, + }), [backgrounds, svg, noiseIntensity]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === "r" || e.key === "R") { + e.preventDefault(); + regenerate(); + } else if (e.key === "e" || e.key === "E") { + e.preventDefault(); + setExportModalOpen(true); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [regenerate]); + return ( -
-
-
-
- - -
-
-
- -
-
-
+ <> + {/* Full-screen gradient background */} +
+
-
+ + {/* Fixed overlay panel on left */} +
+ + setExportModalOpen(true)} + /> + +
+ + {/* Export modal */} + setExportModalOpen(false)} + exportData={exportData} + /> + ); } diff --git a/src/test/App.test.tsx b/src/test/App.test.tsx index e69329e..3352a6e 100644 --- a/src/test/App.test.tsx +++ b/src/test/App.test.tsx @@ -31,14 +31,14 @@ describe("App", () => { expect(() => render(h(App, {}))).not.toThrow(); }); - it("renders the Post component with main heading", () => { + it("renders the main heading", () => { const { getAllByText } = render(h(App, {})); expect(getAllByText("Grain").length).toBeGreaterThan(0); }); - it("renders author information", () => { + it("renders the tagline", () => { const { getAllByText } = render(h(App, {})); - expect(getAllByText("Ryan Walters").length).toBeGreaterThan(0); + expect(getAllByText("Neural Gradient Explorer").length).toBeGreaterThan(0); }); it("renders with gradient class", () => { diff --git a/src/utils/exportGradient.ts b/src/utils/exportGradient.ts new file mode 100644 index 0000000..f96cb12 --- /dev/null +++ b/src/utils/exportGradient.ts @@ -0,0 +1,86 @@ +import html2canvas from "html2canvas"; + +export interface ExportData { + backgrounds: string[]; + svg: string; + noiseIntensity: number; +} + +/** + * Export the current gradient as a PNG image + */ +export async function exportAsPNG(elementId: string = "gradient-container"): Promise { + const element = document.getElementById(elementId); + if (!element) { + throw new Error("Gradient element not found"); + } + + try { + const canvas = await html2canvas(element, { + backgroundColor: null, + scale: 2, // Higher quality + logging: false, + }); + + canvas.toBlob((blob) => { + if (!blob) return; + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `grain-gradient-${Date.now()}.png`; + link.click(); + URL.revokeObjectURL(url); + }); + } catch (error) { + console.error("Failed to export PNG:", error); + throw error; + } +} + +/** + * Generate CSS code for the current gradient + */ +export function generateCSS(data: ExportData): string { + const { backgrounds, svg } = data; + + const backgroundLayers = [`url("${svg}")`, ...backgrounds].join(",\n "); + + return `/* Grain Gradient CSS */ +.gradient-background { + background: ${backgroundLayers}; + filter: contrast(150%) brightness(90%); + background-blend-mode: overlay; +} + +/* Additional overlay (optional) */ +.gradient-overlay { + background: rgba(40, 40, 40, 0.5); + background-blend-mode: overlay; +}`; +} + +/** + * Copy CSS code to clipboard + */ +export async function copyCSSToClipboard(css: string): Promise { + try { + await navigator.clipboard.writeText(css); + } catch (error) { + console.error("Failed to copy to clipboard:", error); + throw error; + } +} + +/** + * Download CSS code as a file + */ +export function downloadCSS(css: string): void { + const blob = new Blob([css], { type: "text/css" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `grain-gradient-${Date.now()}.css`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/src/utils/palettes.ts b/src/utils/palettes.ts new file mode 100644 index 0000000..981a560 --- /dev/null +++ b/src/utils/palettes.ts @@ -0,0 +1,52 @@ +export interface ColorPalette { + id: string; + name: string; + colors: string[]; +} + +export const COLOR_PALETTES: ColorPalette[] = [ + { + id: "classic", + name: "Classic", + colors: ["#ed625d", "#42b6c6", "#f79f88", "#446ba6", "#4b95f0", "#d16ba5"] + }, + { + id: "sunset", + name: "Sunset", + colors: ["#ff6b6b", "#f9ca24", "#ee5a6f", "#c56cf0", "#ff9ff3", "#feca57"] + }, + { + id: "ocean", + name: "Ocean", + colors: ["#0077be", "#00d4ff", "#0099cc", "#66d3ff", "#1e90ff", "#4ecdc4"] + }, + { + id: "forest", + name: "Forest", + colors: ["#2ecc71", "#27ae60", "#16a085", "#1abc9c", "#52c97f", "#00b894"] + }, + { + id: "aurora", + name: "Aurora", + colors: ["#a29bfe", "#6c5ce7", "#fd79a8", "#e17055", "#00b894", "#00cec9"] + }, + { + id: "fire", + name: "Fire", + colors: ["#ff4757", "#ff6348", "#ff7f50", "#ffa502", "#ff6b81", "#ee5a6f"] + }, + { + id: "cosmic", + name: "Cosmic", + colors: ["#5f27cd", "#341f97", "#ee5a6f", "#c44569", "#f368e0", "#ff9ff3"] + }, + { + id: "monochrome", + name: "Monochrome", + colors: ["#2d3436", "#636e72", "#b2bec3", "#dfe6e9", "#74b9ff", "#a29bfe"] + } +]; + +export function getPaletteById(id: string): ColorPalette { + return COLOR_PALETTES.find(p => p.id === id) || COLOR_PALETTES[0]; +} diff --git a/src/utils/useBackground.tsx b/src/utils/useBackground.tsx index 7947b42..54aa5fe 100644 --- a/src/utils/useBackground.tsx +++ b/src/utils/useBackground.tsx @@ -1,10 +1,14 @@ import { Random } from "random-js"; -import { useMemo, useState } from "preact/hooks"; +import { useMemo, useState, useEffect } from "preact/hooks"; import { getEdgePoint } from "./helpers"; + interface useBackgroundProps { width: number; height: number; ratio: number; + paletteColors: string[]; + gradientCount?: number; + noiseIntensity?: number; } interface useBackgroundReturn { @@ -14,16 +18,11 @@ interface useBackgroundReturn { } const random = new Random(); -const palettes = [ - // ["#5e1e1e", "#141414", "#400000", "#7a0000", "#2b0059", "#000c59", "#850082", "#850052"], - ["#ed625d", "#42b6c6", "#f79f88", "#446ba6", "#4b95f0", "#d16ba5"], -]; -const generateBackground = (): string[] => { - const palette = random.pick(palettes); - return Array(5) +const generateBackground = (paletteColors: string[], count: number): string[] => { + return Array(count) .fill(null) - .map(() => random.pick(palette)) + .map(() => random.pick(paletteColors)) .map((color) => { const [x, y] = getEdgePoint(random.integer(0, 400), 100, 100); return `radial-gradient(farthest-corner at ${x}% ${y}%, ${color}, transparent 100%)`; @@ -34,11 +33,19 @@ const useBackground = ({ width, height, ratio, + paletteColors, + gradientCount = 5, + noiseIntensity = 0.9, }: useBackgroundProps): useBackgroundReturn => { - const [background, setBackground] = useState(generateBackground()); + const [background, setBackground] = useState(() => generateBackground(paletteColors, gradientCount)); + + // Regenerate when palette or count changes + useEffect(() => { + setBackground(generateBackground(paletteColors, gradientCount)); + }, [paletteColors, gradientCount]); const regenerate = () => { - setBackground(generateBackground()); + setBackground(generateBackground(paletteColors, gradientCount)); }; const noise = (): string => { @@ -46,7 +53,7 @@ const useBackground = ({ const svgHeight = Math.ceil((height ?? 1080) * ratio); const seed = random.integer(0, 1000000); - return ``; + return ``; }; return {