mirror of
https://github.com/Xevion/grain.git
synced 2025-12-09 06:07:20 -06:00
Compare commits
8 Commits
30d4570997
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5090b9c10 | ||
|
|
6834aa308f | ||
|
|
e632e69b91 | ||
| d668a21750 | |||
| 75913606f4 | |||
| 4d0bdeac7e | |||
| 683e504c9c | |||
| 048701580d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
stats.html
|
||||||
|
*.js
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -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
|
[](LICENSE)
|
||||||
colorful gradients & film grain. Built in <b>React</b> & <b>Vite</b> with <b>SVGs</b> and layers of <b>Radial Gradients</b>
|
[](https://nodejs.org/)
|
||||||
|
[](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
|
- **Customizable gradients & noise** via SVG filters
|
||||||
- React
|
- **Preset palettes** for quick styling
|
||||||
- Typescript
|
- **PNG export** with a single click
|
||||||
- Vite
|
|
||||||
- Sass
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
npm install --global yarn # If you don't have yarn installed
|
git clone https://github.com/Xevion/grain.git # Clone the repository
|
||||||
yarn # Run inside root directory to install all dependencies.
|
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
|
Licensed under the [GNU General Public License v3.0](LICENSE).
|
||||||
yarn dev # Starts a development server with Hot Module Replacement
|
|
||||||
```
|
|
||||||
|
|
||||||
[grain-banner]: ./.media/banner.jpeg
|
[grain-banner]: ./.media/banner.jpeg
|
||||||
|
[grain-website]: https://grain.xevion.dev/
|
||||||
|
|||||||
22
index.html
22
index.html
@@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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: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:type" content="website" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
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:url" content="https://grain.xevion.dev/" />
|
||||||
<meta property="og:site_name" content="Grain" />
|
<meta property="og:site_name" content="Grain" />
|
||||||
@@ -19,10 +19,7 @@
|
|||||||
property="article:published_time"
|
property="article:published_time"
|
||||||
content="2022-11-25T08:54:58.977Z "
|
content="2022-11-25T08:54:58.977Z "
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta property="og:image" content="https://grain.xevion.dev/bg.jpeg" />
|
||||||
property="og:image"
|
|
||||||
content="https://grain.xevion.dev/bg.jpeg"
|
|
||||||
/>
|
|
||||||
<meta
|
<meta
|
||||||
property="og:image:secure_url"
|
property="og:image:secure_url"
|
||||||
content="https://grain.xevion.dev/bg.jpeg"
|
content="https://grain.xevion.dev/bg.jpeg"
|
||||||
@@ -34,21 +31,18 @@
|
|||||||
content="A simple gradient image generated with Grain."
|
content="A simple gradient image generated with Grain."
|
||||||
/>
|
/>
|
||||||
<meta property="og:image:type" content="jpeg" />
|
<meta property="og:image:type" content="jpeg" />
|
||||||
<meta
|
<meta name="twitter:image" content="https://grain.xevion.dev/bg.jpeg" />
|
||||||
name="twitter:image"
|
|
||||||
content="https://grain.xevion.dev/bg.jpeg"
|
|
||||||
/>
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:url" content="https://grain.xevion.dev/" />
|
<meta name="twitter:url" content="https://grain.xevion.dev/" />
|
||||||
<meta name="twitter:domain" 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
|
<meta
|
||||||
name="twitter:description"
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script prerender type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
63
package.json
63
package.json
@@ -1,33 +1,52 @@
|
|||||||
{
|
{
|
||||||
"name": "noise",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"preinstall": "npx only-allow pnpm",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.0.13",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@use-it/event-listener": "^0.1.7",
|
"@fontsource/roboto-mono": "^5.2.8",
|
||||||
"autoprefixer": "^10.4.13",
|
"@fontsource/space-grotesk": "^5.2.10",
|
||||||
"chance": "^1.1.9",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"postcss": "^8.4.19",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"react": "^18.2.0",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"react-dom": "^18.2.0",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"sass": "^1.56.1",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"tailwindcss": "^3.2.4",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"usehooks-ts": "^2.9.1"
|
"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",
|
||||||
|
"random-js": "^2.1.0",
|
||||||
|
"tailwindcss": "^4.1.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chance": "^1.1.3",
|
"@preact/preset-vite": "^2.10.2",
|
||||||
"@types/node": "^18.11.9",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/react": "^18.0.24",
|
"@testing-library/preact": "^3.2.4",
|
||||||
"@types/react-dom": "^18.0.8",
|
"@types/node": "^24.10.0",
|
||||||
"@vitejs/plugin-react": "^2.2.0",
|
"@vitest/ui": "4.0.7",
|
||||||
"typescript": "^4.6.4",
|
"eslint": "^9.39.1",
|
||||||
"vite": "^3.2.4",
|
"eslint-config-preact": "^2.0.0",
|
||||||
"vite-tsconfig-paths": "^3.6.0"
|
"happy-dom": "^20.0.10",
|
||||||
}
|
"rollup-plugin-visualizer": "^6.0.5",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.1",
|
||||||
|
"vitest": "^4.0.7"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "preact"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf"
|
||||||
}
|
}
|
||||||
|
|||||||
5844
pnpm-lock.yaml
generated
Normal file
5844
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useInterval, useWindowSize, useToggle } from "usehooks-ts";
|
|
||||||
import useBackground from "@/utils/useBackground";
|
|
||||||
import Post from "@/components/Post";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowPathIcon,
|
|
||||||
EyeIcon,
|
|
||||||
EyeSlashIcon,
|
|
||||||
} from "@heroicons/react/24/solid";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const { width, height } = useWindowSize();
|
|
||||||
const { svg, backgrounds, regenerate } = useBackground({
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
ratio: 0.4,
|
|
||||||
});
|
|
||||||
const [postHidden, toggleHidden] = useToggle(false);
|
|
||||||
|
|
||||||
const [iconSpinning, , setIconSpinning] = useToggle(false);
|
|
||||||
useInterval(
|
|
||||||
() => {
|
|
||||||
setIconSpinning(false);
|
|
||||||
},
|
|
||||||
iconSpinning ? 200 : null
|
|
||||||
);
|
|
||||||
|
|
||||||
const style = useMemo(() => {
|
|
||||||
return {
|
|
||||||
background: [`url("${svg}")`, ...backgrounds].join(", "),
|
|
||||||
};
|
|
||||||
}, [svg, backgrounds]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className="text-zinc-800 gradient max-w-[100vw] max-h-[100vh] 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={() => {
|
|
||||||
setIconSpinning(true);
|
|
||||||
regenerate();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowPathIcon
|
|
||||||
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 ? <EyeIcon /> : <EyeSlashIcon />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`h-[100vh] 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-[100vh]`}
|
|
||||||
|
|
||||||
>
|
|
||||||
<div className="bg-white overflow-y-auto">
|
|
||||||
<Post />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
58
src/components/GlassButton.tsx
Normal file
58
src/components/GlassButton.tsx
Normal 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;
|
||||||
|
}
|
||||||
32
src/components/GlassPanel.tsx
Normal file
32
src/components/GlassPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/GradientControls.tsx
Normal file
98
src/components/GradientControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/PaletteSelector.tsx
Normal file
63
src/components/PaletteSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { SparklesIcon } from "@heroicons/react/20/solid";
|
|
||||||
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
|
|
||||||
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<SparklesIcon 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{" "}
|
|
||||||
<pre className="inline"><feTurbulence></pre> filter inside,
|
|
||||||
stacked upon several <pre className="inline">radial-gradient</pre>{" "}
|
|
||||||
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 <pre className="inline">base64</pre> encoded string.
|
|
||||||
As a <pre className="inline">base64</pre> image, it can be fed into
|
|
||||||
the <pre className="inline">background</pre> 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-gradient-to-r from-red-500 via-orange-500 to-orange-700">
|
|
||||||
In Progress
|
|
||||||
<ShieldExclamationIcon className="inline h-[1.4rem] ml-3 drop-shadow-2xl" />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Post;
|
|
||||||
52
src/components/SliderControl.tsx
Normal file
52
src/components/SliderControl.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/index.css
Normal file
108
src/index.css
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
@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-space-grotesk: "Space Grotesk", sans-serif;
|
||||||
|
--font-family-inter: "Inter", sans-serif;
|
||||||
|
--font-family-mono: "Roboto Mono", monospace;
|
||||||
|
|
||||||
|
--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);
|
||||||
|
--box-shadow-inner-md-3: inset 2px 4px 6px 0 rgb(0 0 0 / 0.21);
|
||||||
|
--box-shadow-inner-md-4: inset 2px 4px 10px 0 rgb(0 0 0 / 0.28);
|
||||||
|
--box-shadow-inner-lg: inset 4px 5px 7px 0 rgb(0 0 0 / 0.2);
|
||||||
|
--box-shadow-inner-xl: inset 4px 9px 9px 0 rgb(0 0 0 / 0.3);
|
||||||
|
--box-shadow-inner-2xl: inset 4px 11px 12px 0 rgb(0 0 0 / 0.3);
|
||||||
|
|
||||||
|
--screens-xs: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
|
||||||
|
@apply text-zinc-800 bg-zinc-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply min-w-screen min-h-screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient {
|
||||||
|
filter: contrast(150%) brightness(90%);
|
||||||
|
background-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
@apply border-zinc-50/70 border outline-none focus:outline-2 outline-offset-2 focus:outline-fuchsia-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semibold-children {
|
||||||
|
& b {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
90
src/index.tsx
Normal file
90
src/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { hydrate, prerender as ssr } from "preact-iso";
|
||||||
|
import { useMemo, useState, useEffect } from "preact/hooks";
|
||||||
|
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
import { useViewportSize } from "./utils/useViewportSize";
|
||||||
|
import useBackground from "./utils/useBackground";
|
||||||
|
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 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 (
|
||||||
|
<>
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
hydrate(<App />, document.getElementById("root"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prerender(data: any) {
|
||||||
|
return await ssr(<App {...data} />);
|
||||||
|
}
|
||||||
10
src/main.tsx
10
src/main.tsx
@@ -1,10 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import App from "@/components/App";
|
|
||||||
import "@/styles/index.scss";
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
@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");
|
|
||||||
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
|
||||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 24px;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
|
|
||||||
@apply text-zinc-800 bg-zinc-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply min-w-[100vw] min-h-[100vh];
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient {
|
|
||||||
filter: contrast(150%) brightness(90%);
|
|
||||||
background-blend-mode: overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
@apply border-zinc-50/70 border outline-none focus:outline-2 outline-offset-2 focus:outline-fuchsia-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.semibold-children {
|
|
||||||
& b {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.inline {
|
|
||||||
@apply text-zinc-900;
|
|
||||||
}
|
|
||||||
48
src/test/App.test.tsx
Normal file
48
src/test/App.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { h } from "preact";
|
||||||
|
import { render } from "@testing-library/preact";
|
||||||
|
import type { FunctionComponent } from "preact";
|
||||||
|
|
||||||
|
describe("App", () => {
|
||||||
|
let App: FunctionComponent;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock window dimensions for useViewportSize hook
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 1920,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 1080,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock btoa for useBackground hook
|
||||||
|
global.btoa = vi.fn((str) => Buffer.from(str).toString("base64"));
|
||||||
|
|
||||||
|
// Dynamically import App after mocks are set up
|
||||||
|
const module = await import("../index");
|
||||||
|
App = module.App;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without crashing", () => {
|
||||||
|
expect(() => render(h(App, {}))).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the main heading", () => {
|
||||||
|
const { getAllByText } = render(h(App, {}));
|
||||||
|
expect(getAllByText("Grain").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the tagline", () => {
|
||||||
|
const { getAllByText } = render(h(App, {}));
|
||||||
|
expect(getAllByText("Neural Gradient Explorer").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with gradient class", () => {
|
||||||
|
const { container } = render(h(App, {}));
|
||||||
|
expect(container.innerHTML).toContain("gradient");
|
||||||
|
});
|
||||||
|
});
|
||||||
2
src/test/setup.ts
Normal file
2
src/test/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import "preact/debug";
|
||||||
20
src/utils/exportGradient.ts
Normal file
20
src/utils/exportGradient.ts
Normal 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
52
src/utils/palettes.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Chance } from "chance";
|
import { Random } from "random-js";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useEffect } from "preact/hooks";
|
||||||
import ReactDOMServer from "react-dom/server";
|
import { getEdgePoint } from "./helpers";
|
||||||
import { getEdgePoint } from "@/utils/helpers";
|
|
||||||
|
|
||||||
interface useBackgroundProps {
|
interface useBackgroundProps {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
ratio: number;
|
ratio: number;
|
||||||
|
paletteColors: string[];
|
||||||
|
gradientCount?: number;
|
||||||
|
noiseIntensity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface useBackgroundReturn {
|
interface useBackgroundReturn {
|
||||||
@@ -15,23 +17,14 @@ interface useBackgroundReturn {
|
|||||||
svg: string;
|
svg: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chance = Chance();
|
const random = new Random();
|
||||||
const palettes = [
|
|
||||||
// ["#5e1e1e", "#141414", "#400000", "#7a0000", "#2b0059", "#000c59", "#850082", "#850052"],
|
|
||||||
["#ed625d", "#42b6c6", "#f79f88", "#446ba6", "#4b95f0", "#d16ba5"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const generateBackground = (): string[] => {
|
const generateBackground = (paletteColors: string[], count: number): string[] => {
|
||||||
const palette = chance.pick(palettes);
|
return Array(count)
|
||||||
return Array(5)
|
|
||||||
.fill(null)
|
.fill(null)
|
||||||
.map(() => chance.pickone(palette))
|
.map(() => random.pick(paletteColors))
|
||||||
.map((color) => {
|
.map((color) => {
|
||||||
const [x, y] = getEdgePoint(
|
const [x, y] = getEdgePoint(random.integer(0, 400), 100, 100);
|
||||||
chance.integer({ min: 0, max: 400 }),
|
|
||||||
100,
|
|
||||||
100
|
|
||||||
);
|
|
||||||
return `radial-gradient(farthest-corner at ${x}% ${y}%, ${color}, transparent 100%)`;
|
return `radial-gradient(farthest-corner at ${x}% ${y}%, ${color}, transparent 100%)`;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -40,42 +33,35 @@ const useBackground = ({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
ratio,
|
ratio,
|
||||||
|
paletteColors,
|
||||||
|
gradientCount = 5,
|
||||||
|
noiseIntensity = 0.9,
|
||||||
}: useBackgroundProps): useBackgroundReturn => {
|
}: 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 = () => {
|
const regenerate = () => {
|
||||||
setBackground(generateBackground());
|
setBackground(generateBackground(paletteColors, gradientCount));
|
||||||
};
|
};
|
||||||
|
|
||||||
const noise = useMemo(() => {
|
const noise = (): string => {
|
||||||
const svgWidth = Math.ceil((width ?? 1920) * ratio);
|
const svgWidth = Math.ceil((width ?? 1920) * ratio);
|
||||||
const svgHeight = Math.ceil((height ?? 1080) * ratio);
|
const svgHeight = Math.ceil((height ?? 1080) * ratio);
|
||||||
return (
|
const seed = random.integer(0, 1000000);
|
||||||
<svg
|
|
||||||
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
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>`;
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
};
|
||||||
>
|
|
||||||
<filter id="noiseFilter">
|
|
||||||
<feTurbulence
|
|
||||||
type="fractalNoise"
|
|
||||||
baseFrequency="2.1"
|
|
||||||
numOctaves="2"
|
|
||||||
seed={chance.natural()}
|
|
||||||
stitchTiles="stitch"
|
|
||||||
/>
|
|
||||||
</filter>
|
|
||||||
<g opacity={0.9}>
|
|
||||||
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}, [width, height, ratio]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
regenerate,
|
regenerate,
|
||||||
svg: `data:image/svg+xml;base64,${window.btoa(
|
svg:
|
||||||
ReactDOMServer.renderToString(noise)
|
typeof window !== "undefined"
|
||||||
)}`,
|
? `data:image/svg+xml;base64,${window.btoa(noise())}`
|
||||||
|
: "",
|
||||||
backgrounds: background,
|
backgrounds: background,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
18
src/utils/useBooleanToggle.ts
Normal file
18
src/utils/useBooleanToggle.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useCallback, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
export function useBooleanToggle(
|
||||||
|
initialValue: boolean = false
|
||||||
|
): [boolean, (nextValue?: boolean) => void] {
|
||||||
|
const [value, setValue] = useState<boolean>(initialValue);
|
||||||
|
|
||||||
|
const toggle = useCallback((nextValue?: boolean) => {
|
||||||
|
if (typeof nextValue === "boolean") {
|
||||||
|
setValue(nextValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValue((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [value, toggle];
|
||||||
|
}
|
||||||
|
|
||||||
24
src/utils/useViewportSize.ts
Normal file
24
src/utils/useViewportSize.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
export interface ViewportSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useViewportSize(): ViewportSize {
|
||||||
|
const [size, setSize] = useState<ViewportSize>({
|
||||||
|
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||||
|
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setSize({ width: window.innerWidth, height: window.innerHeight });
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
screens: {
|
|
||||||
xs: "450px",
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
"inner-md": "inset 1px 4px 6px 0 rgb(0 0 0 / 0.1)",
|
|
||||||
"inner-md-2": "inset 2px 2px 6px 0 rgb(0 0 0 / 0.15)",
|
|
||||||
"inner-md-3": "inset 2px 4px 6px 0 rgb(0 0 0 / 0.21)",
|
|
||||||
"inner-md-4": "inset 2px 4px 10px 0 rgb(0 0 0 / 0.28)",
|
|
||||||
"inner-lg": "inset 4px 5px 7px 0 rgb(0 0 0 / 0.2)",
|
|
||||||
"inner-xl": "inset 4px 9px 9px 0 rgb(0 0 0 / 0.3)",
|
|
||||||
"inner-2xl": "inset 4px 11px 12px 0 rgb(0 0 0 / 0.3)",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
opensans: ['"Open Sans"', "sans-serif"],
|
|
||||||
inter: ['"Inter"', "sans-serif"],
|
|
||||||
mono: ['"Roboto Mono"', "monospace"],
|
|
||||||
raleway: ['"Raleway"', "sans-serif"],
|
|
||||||
roboto: ['"Roboto"'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": false,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
|
||||||
|
/* Preact Config */
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"baseUrl": "./src/",
|
"jsxImportSource": "preact",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"react": ["node_modules/preact/compat/"],
|
||||||
}
|
"react-dom": ["node_modules/preact/compat/"],
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"outDir": "./dist"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["node_modules/vite/client.d.ts", "src"]
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import preact from "@preact/preset-vite";
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { visualizer } from "rollup-plugin-visualizer";
|
||||||
|
import cssnano from "cssnano";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default ({mode }) => {
|
export default ({ mode }) => {
|
||||||
process.env = {...process.env, ...loadEnv(mode, process.cwd())};
|
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
|
||||||
|
|
||||||
return defineConfig({
|
return defineConfig({
|
||||||
base: '/',
|
base: "/",
|
||||||
plugins: [react(), tsconfigPaths()],
|
plugins: [
|
||||||
})
|
preact({
|
||||||
}
|
prerender: {
|
||||||
|
enabled: true,
|
||||||
|
renderTarget: "#root",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tailwindcss(),
|
||||||
|
visualizer({
|
||||||
|
template: "treemap",
|
||||||
|
open: true, // Automatically open the report in your browser after build
|
||||||
|
filename: "stats.html", // Output file name
|
||||||
|
gzipSize: true, // Show gzip size
|
||||||
|
brotliSize: true, // Show brotli size
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
28
vitest.config.ts
Normal file
28
vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import preact from "@preact/preset-vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
test: {
|
||||||
|
environment: "happy-dom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
coverage: {
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
exclude: [
|
||||||
|
"node_modules/",
|
||||||
|
"src/test/",
|
||||||
|
"**/*.config.ts",
|
||||||
|
"**/*.d.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"react": "preact/compat",
|
||||||
|
"react-dom": "preact/compat",
|
||||||
|
"react-dom/test-utils": "preact/test-utils",
|
||||||
|
},
|
||||||
|
dedupe: ["preact"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user