Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Walters
f5090b9c10 refactor: simplify export to PNG-only, improve controls UI
- Replace html2canvas with modern-screenshot for better performance
- Remove ExportModal and CSS export functionality
- Simplify export to single PNG download
- Improve controls layout with better spacing and text shadows
- Increase glass-panel opacity for better readability
- Remove unused ExportData interface and CSS generation utilities
2025-11-06 23:40:43 -06:00
Ryan Walters
6834aa308f feat: add interactive gradient controls with glassmorphic UI
Implement comprehensive gradient customization with neural noise textures, including palettes, export functionality, and Radix UI components with framer-motion animations.
2025-11-06 22:55:06 -06:00
16 changed files with 1365 additions and 165 deletions

View File

@@ -1,29 +1,32 @@
![Grain Project - Banner Image][grain-banner]
[![Grain Project - Banner Image][grain-banner]][grain-website]
A small experiment on creating beautiful, dynamic backgrounds with
colorful gradients & film grain. Built in <b>React</b> & <b>Vite</b> with <b>SVGs</b> and layers of <b>Radial Gradients</b>
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
[![Node Version](https://img.shields.io/badge/node-v22-brightgreen.svg)](https://nodejs.org/)
[![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg)](https://pnpm.io/)
### Dependencies Used
Create beautiful, dynamic backgrounds with colorful gradients and film grain. Built with modern web technologies for smooth performance and stunning visuals.
- Hero Icons
- React
- Typescript
- Vite
- Sass
- **Customizable gradients & noise** via SVG filters
- **Preset palettes** for quick styling
- **PNG export** with a single click
### Installation
Built with [Preact](https://preactjs.com/), [Vite](https://vite.dev/), [Tailwind CSS](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), and [Lucide Icons](https://lucide.dev/).
- Built on Node v16, packages managed with Yarn.
## Usage
```bash
npm install --global yarn # If you don't have yarn installed
yarn # Run inside root directory to install all dependencies.
git clone https://github.com/Xevion/grain.git # Clone the repository
cd grain # Navigate to the repository
npm install --global pnpm # Install pnpm if needed
pnpm install # Install dependencies
pnpm dev # Start development server
pnpm build # Build for production
pnpm preview # Preview the production build
```
### Development
## License
```bash
yarn dev # Starts a development server with Hot Module Replacement
```
Licensed under the [GNU General Public License v3.0](LICENSE).
[grain-banner]: ./.media/banner.jpeg
[grain-banner]: ./.media/banner.jpeg
[grain-website]: https://grain.xevion.dev/

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Grain | Dynamic Gradients & Noise</title>
<title>Grain | Neural Gradient Explorer</title>
<meta property="og:locale" content="en_US" />
<meta property="og:title" content="Grain" />
<meta property="og:title" content="Grain - Neural Gradient Explorer" />
<meta property="og:type" content="website" />
<meta
property="og:description"
content="A simple demonstration of a dynamically scaled SVG-based noise & radial gradients."
content="Interactive gradient explorer with neural noise textures. Create, customize, and export beautiful gradients with glassmorphic UI."
/>
<meta property="og:url" content="https://grain.xevion.dev/" />
<meta property="og:site_name" content="Grain" />
@@ -35,10 +35,10 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://grain.xevion.dev/" />
<meta name="twitter:domain" content="https://grain.xevion.dev/" />
<meta name="twitter:title" content="Grain" />
<meta name="twitter:title" content="Grain - Neural Gradient Explorer" />
<meta
name="twitter:description"
content="A simple demonstration of a dynamically scaled SVG-based noise & stacked radial gradients."
content="Interactive gradient explorer with neural noise textures. Create, customize, and export beautiful gradients with glassmorphic UI."
/>
</head>
<body>

View File

@@ -12,9 +12,19 @@
"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",
"lucide-preact": "^0.468.0",
"modern-screenshot": "^4.6.6",
"preact": "^10.27.2",
"preact-iso": "^2.11.0",
"preact-render-to-string": "^6.6.3",

822
pnpm-lock.yaml generated
View File

@@ -8,15 +8,45 @@ 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)
lucide-preact:
specifier: ^0.468.0
version: 0.468.0(preact@10.27.2)
modern-screenshot:
specifier: ^4.6.6
version: 4.6.6
preact:
specifier: ^10.27.2
version: 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==}
@@ -1086,6 +1483,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 +1709,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 +1750,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'}
@@ -1730,6 +2148,15 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
modern-screenshot@4.6.6:
resolution: {integrity: sha512-8tF0xEpe7yx37mK95UcIghSCWYeu628K2hLJl+ZNY2ANmRzYLlRLpquPHAQcL8keF6BoeEzTEw4GrgmUpGuZ8w==}
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 +2474,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 +2585,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
@@ -2279,6 +2748,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,6 +2795,26 @@ 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==}
@@ -2746,6 +3238,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 +3410,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 +3905,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
@@ -3461,6 +4219,8 @@ snapshots:
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -3827,6 +4587,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 +4629,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
@@ -4211,6 +4982,14 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
modern-screenshot@4.6.6: {}
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 +5293,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 +5428,8 @@ snapshots:
sax@1.4.1: {}
scheduler@0.27.0: {}
semver@6.3.1: {}
semver@7.7.2: {}
@@ -4812,6 +5621,8 @@ snapshots:
totalist@3.0.1: {}
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -4872,6 +5683,17 @@ 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: {}
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)):

View File

@@ -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 = (
<motion.button
className={`glass-button rounded-xl px-4 py-2 font-space-grotesk font-semibold text-zinc-900 outline-none focus:outline-2 outline-offset-2 focus:outline-fuchsia-500 disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
whileHover={!disabled ? { scale: 1.02, y: -2 } : {}}
whileTap={!disabled ? { scale: 0.98, y: 0 } : {}}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
{children}
</motion.button>
);
if (tooltip) {
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{button}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="glass-card px-3 py-2 rounded-lg text-sm text-zinc-900 font-inter"
sideOffset={5}
>
{tooltip}
<Tooltip.Arrow className="fill-white/20" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
return button;
}

View File

@@ -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 (
<Component
className={`glass-panel rounded-2xl ${className}`}
{...animationProps}
>
{children}
</Component>
);
}

View File

@@ -0,0 +1,98 @@
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 (
<div className="flex flex-col h-full">
{/* Header */}
<div className="pl-4 pt-3 pb-2">
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-50 text-shadow-md">
Grain
</h1>
</div>
<Separator.Root className="h-px bg-white/20" />
{/* Controls */}
{/* Palette Selector */}
<PaletteSelector
className="px-6 py-4"
selectedPaletteId={paletteId}
onSelect={onPaletteChange}
/>
<Separator.Root className="h-px bg-white/20" />
{/* Gradient Count Slider */}
<SliderControl
label="Gradients"
value={gradientCount}
onChange={onGradientCountChange}
min={2}
max={8}
step={1}
className="px-6 py-4"
/>
{/* Noise Intensity Slider */}
<SliderControl
label="Noise"
value={noiseIntensity}
onChange={onNoiseIntensityChange}
min={0}
max={1.5}
step={0.1}
formatValue={(v) => `${Math.round(v * 100)}%`}
className="px-6 py-4"
/>
<Separator.Root className="h-px bg-white/20" />
{/* Action Buttons */}
<div className="flex flex-col gap-2 mx-6 my-4">
<GlassButton
onClick={onRegenerate}
className="w-full flex items-center justify-center gap-2"
tooltip="Regenerate gradient (R)"
aria-label="Regenerate gradient"
>
<RefreshCw size={18} />
<span>Regenerate</span>
</GlassButton>
<GlassButton
onClick={onExport}
className="w-full flex items-center justify-center gap-2"
tooltip="Export gradient (E)"
aria-label="Export gradient"
>
<Download size={18} />
<span>Export</span>
</GlassButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { COLOR_PALETTES, type ColorPalette } from "../utils/palettes";
import { motion } from "framer-motion";
interface PaletteSelectorProps {
selectedPaletteId: string;
onSelect: (paletteId: string) => void;
className?: string;
}
export function PaletteSelector({
selectedPaletteId,
onSelect,
className,
}: PaletteSelectorProps) {
return (
<div className={`space-y-2 ${className}`}>
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-50 text-shadow-md">
Color Palette
</label>
<div className="grid grid-cols-2 gap-2">
{COLOR_PALETTES.map((palette) => (
<PaletteSwatch
key={palette.id}
palette={palette}
isSelected={palette.id === selectedPaletteId}
onClick={() => onSelect(palette.id)}
/>
))}
</div>
</div>
);
}
interface PaletteSwatchProps {
palette: ColorPalette;
isSelected: boolean;
onClick: () => void;
}
function PaletteSwatch({ palette, isSelected, onClick }: PaletteSwatchProps) {
return (
<motion.button
className="glass-input rounded-lg p-2 flex flex-col gap-1.5 cursor-pointer outline-none transition-all"
onClick={onClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<div className="flex gap-0.5 h-3">
{palette.colors.slice(0, 6).map((color, index) => (
<div
key={index}
className="flex-1 rounded-sm"
style={{ backgroundColor: color }}
/>
))}
</div>
<span className="text-xs font-inter text-zinc-900 text-center">
{palette.name}
</span>
</motion.button>
);
}

View File

@@ -1,75 +0,0 @@
import { Sparkles, ShieldAlert } from "lucide-preact";
const Post = () => {
return (
<>
<div className="m-5 md:m-8 md:mt-5 w-[90%]">
<div className="mb-5">
<h2 className="text-3xl md:text-4xl pb-1 tracking-wide font-semibold drop-shadow-xl">
Grain
</h2>
<span className="pl-1 py-1 text-zinc-500">
Created by{" "}
<a
href="https://xevion.dev"
target="_blank"
className="transition-colors text-sky-800 hover:text-sky-600"
>
Ryan Walters
</a>
<a
href="https://github.com/Xevion"
target="_blank"
className="hover:text-yellow-600 transition-colors cursor-pointer"
>
<Sparkles className="h-4 inline mb-2.5 m-2 " />
</a>
</span>
</div>
<div className="space-y-4">
<p className="semibold-children">
A small experiment on creating beautiful, dynamic backgrounds with
colorful gradients & film grain. Built in <b>React</b> & <b>Vite</b>{" "}
with <b>SVGs</b> and layers of <b>Radial Gradients</b>.
</p>
<p>
This app was inspired by the gradients used{" "}
<a
href="https://www.instagram.com/p/ClUe3ONJaER/"
target="_blank"
className="text-sky-800 hover:text-sky-600"
>
certain popular instagram post
</a>{" "}
with beautiful gradients and a slight film grain applied. I wanted
to create something similar, but in a website form.
</p>
<p>
By using a SVG with a{" "}
<code>&lt;feTurbulence&gt;</code> filter inside,
stacked upon several <code>radial-gradient</code>{" "}
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.
</p>
<p>
React comes in handy here, allowing composition of an SVG, and then
conversion to a <code>base64</code> encoded string.
As a <code>base64</code> image, it can be fed into
the <code>background</code> CSS property, allowing
dynamic SVG generation.
</p>
<div className="pt-3">
<a href="https://github.com/Xevion/grain">
<div className="inline text-white text-medium drop-shadow-lg rounded border-2 shadow-xl border-zinc-600/75 m-2 p-2 bg-linear-to-r from-red-500 via-orange-500 to-orange-700">
In Progress
<ShieldAlert className="inline h-[1.4rem] ml-3 drop-shadow-2xl" />
</div>
</a>
</div>
</div>
</div>
</>
);
};
export default Post;

View File

@@ -0,0 +1,52 @@
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;
className?: string;
}
export function SliderControl({
label,
value,
onChange,
min,
max,
step = 1,
formatValue = (v) => v.toString(),
className,
}: SliderControlProps) {
return (
<div className={`space-y ${className}`}>
<div className="flex justify-between items-center">
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-50 text-shadow-md">
{label}
</label>
<span className="font-inter text-sm text-zinc-100 text-shadow-md">
{formatValue(value)}
</span>
</div>
<Slider.Root
className="relative flex items-center select-none touch-none w-full h-6"
value={[value]}
onValueChange={([newValue]) => onChange(newValue)}
min={min}
max={max}
step={step}
>
<Slider.Track className="glass-input relative grow rounded-full h-3">
<Slider.Range className="absolute bg-white/40 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb
className="block w-6 h-6 glass-button rounded-full focus:outline-none focus:ring-2 focus:ring-fuchsia-500 focus:ring-offset-2 cursor-grab active:cursor-grabbing"
aria-label={label}
/>
</Slider.Root>
</div>
);
}

View File

@@ -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.35);
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);
}

View File

@@ -1,71 +1,83 @@
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 { getPaletteById } from "./utils/palettes";
import { exportGradientAsPNG } 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 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]);
// 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();
exportGradientAsPNG();
}
};
window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, [regenerate]);
return (
<div
style={style}
className="text-zinc-800 gradient max-w-screen max-h-screen overflow-clip"
>
<div className="font-inter w-full h-full bg-zinc-800/50 bg-blend-overlay">
<div className="grid grid-cols-12 w-full">
<div className="col-span-3 sm:col-span-2">
<button
className="block p-2 w-10 h-10 rounded mx-auto xs:mx-0 xs:ml-5 mt-5 shadow-inner-md bg-zinc-700 text-zinc-100 button"
onClick={() => {
toggleIconSpinning(true);
regenerate();
setTimeout(() => toggleIconSpinning(false), 200);
}}
>
<RefreshCw
className={`transition-transform duration-200 ${
iconSpinning ? "rotate-180" : "rotate-0"
}`}
/>
</button>
<button
className="block p-2 w-10 h-10 rounded mx-auto xs:mx-0 xs:ml-5 mt-5 shadow-inner-md bg-zinc-700 text-zinc-100 button"
onClick={() => toggleHidden()}
>
{postHidden ? <Eye /> : <EyeOff />}
</button>
</div>
<div
className={`h-screen transition-opacity ease-in-out duration-75 ${
postHidden ? "opacity-0 pointer-events-none" : ""
} flex col-span-9 sm:col-span-6 md:col-span-5 w-full min-h-screen`}
>
<div className="bg-white overflow-y-auto">
<Post />
</div>
</div>
</div>
<>
{/* Full-screen gradient background */}
<div
id="gradient-container"
style={gradientStyle}
className="w-full h-screen gradient text-zinc-800 overflow-clip"
>
<div className="w-full h-full bg-zinc-800/50 bg-blend-overlay" />
</div>
</div>
{/* Fixed overlay panel on left */}
<div className="absolute top-0 left-0 w-[400px] h-screen pointer-events-none">
<GlassPanel className="m-6 h-[calc(100vh-3rem)] pointer-events-auto">
<GradientControls
paletteId={paletteId}
onPaletteChange={setPaletteId}
gradientCount={gradientCount}
onGradientCountChange={setGradientCount}
noiseIntensity={noiseIntensity}
onNoiseIntensityChange={setNoiseIntensity}
onRegenerate={regenerate}
onExport={() => exportGradientAsPNG()}
/>
</GlassPanel>
</div>
</>
);
}

View File

@@ -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", () => {

View File

@@ -0,0 +1,20 @@
import { domToPng } from "modern-screenshot";
/**
* Export the gradient as a PNG screenshot
*/
export async function exportGradientAsPNG(
elementId: string = "gradient-container"
): Promise<void> {
const element = document.querySelector(`#${elementId}`);
if (!element) {
throw new Error("Gradient element not found");
}
const dataUrl = await domToPng(element as HTMLElement);
const link = document.createElement("a");
link.download = `grain-gradient-${Date.now()}.png`;
link.href = dataUrl;
link.click();
}

52
src/utils/palettes.ts Normal file
View File

@@ -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];
}

View File

@@ -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 `<svg viewBox="0 0 ${svgWidth} ${svgHeight}" xmlns="http://www.w3.org/2000/svg"><filter id="noiseFilter"><feTurbulence type="fractalNoise" baseFrequency="2.1" numOctaves="2" seed="${seed}" stitchTiles="stitch"/></filter><g opacity="0.9"><rect width="100%" height="100%" filter="url(#noiseFilter)"/></g></svg>`;
return `<svg viewBox="0 0 ${svgWidth} ${svgHeight}" xmlns="http://www.w3.org/2000/svg"><filter id="noiseFilter"><feTurbulence type="fractalNoise" baseFrequency="2.1" numOctaves="2" seed="${seed}" stitchTiles="stitch"/></filter><g opacity="${noiseIntensity}"><rect width="100%" height="100%" filter="url(#noiseFilter)"/></g></svg>`;
};
return {