mirror of
https://github.com/Xevion/grain.git
synced 2025-12-05 23:15:08 -06:00
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.
This commit is contained in:
10
index.html
10
index.html
@@ -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>
|
||||
|
||||
10
package.json
10
package.json
@@ -12,8 +12,18 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/roboto-mono": "^5.2.8",
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"cssnano": "^7.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lucide-preact": "^0.468.0",
|
||||
"preact": "^10.27.2",
|
||||
"preact-iso": "^2.11.0",
|
||||
|
||||
853
pnpm-lock.yaml
generated
853
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
116
src/components/ExportModal.tsx
Normal file
116
src/components/ExportModal.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { X, Download, Copy, Check } from "lucide-preact";
|
||||
import { GlassButton } from "./GlassButton";
|
||||
import { exportAsPNG, generateCSS, copyCSSToClipboard, downloadCSS, type ExportData } from "../utils/exportGradient";
|
||||
|
||||
interface ExportModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
exportData: ExportData;
|
||||
}
|
||||
|
||||
export function ExportModal({ open, onClose, exportData }: ExportModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const cssCode = generateCSS(exportData);
|
||||
|
||||
const handleCopyCSS = async () => {
|
||||
try {
|
||||
await copyCSSToClipboard(cssCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadCSS = () => {
|
||||
downloadCSS(cssCode);
|
||||
};
|
||||
|
||||
const handleExportPNG = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportAsPNG("gradient-container");
|
||||
} catch (error) {
|
||||
console.error("Failed to export PNG:", error);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-lg">
|
||||
<div className="glass-panel p-6 m-4 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title className="font-space-grotesk text-2xl font-bold text-zinc-900">
|
||||
Export Gradient
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="glass-button rounded-lg p-2 text-zinc-900"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* PNG Export Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
|
||||
Export as Image
|
||||
</h3>
|
||||
<GlassButton
|
||||
onClick={handleExportPNG}
|
||||
disabled={exporting}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download size={18} />
|
||||
<span>{exporting ? "Exporting..." : "Download PNG"}</span>
|
||||
</GlassButton>
|
||||
</div>
|
||||
|
||||
{/* CSS Export Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
|
||||
Export as CSS
|
||||
</h3>
|
||||
|
||||
{/* CSS Code Preview */}
|
||||
<div className="glass-input rounded-lg p-4 overflow-x-auto max-h-48 overflow-y-auto">
|
||||
<pre className="font-mono text-xs text-zinc-900 leading-relaxed">
|
||||
{cssCode}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* CSS Actions */}
|
||||
<div className="flex gap-2">
|
||||
<GlassButton
|
||||
onClick={handleCopyCSS}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
{copied ? <Check size={18} /> : <Copy size={18} />}
|
||||
<span>{copied ? "Copied!" : "Copy CSS"}</span>
|
||||
</GlassButton>
|
||||
<GlassButton
|
||||
onClick={handleDownloadCSS}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download size={18} />
|
||||
<span>Download CSS</span>
|
||||
</GlassButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
97
src/components/GradientControls.tsx
Normal file
97
src/components/GradientControls.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { RefreshCw, Download } from "lucide-preact";
|
||||
import { GlassButton } from "./GlassButton";
|
||||
import { SliderControl } from "./SliderControl";
|
||||
import { PaletteSelector } from "./PaletteSelector";
|
||||
import * as Separator from "@radix-ui/react-separator";
|
||||
|
||||
interface GradientControlsProps {
|
||||
paletteId: string;
|
||||
onPaletteChange: (paletteId: string) => void;
|
||||
gradientCount: number;
|
||||
onGradientCountChange: (count: number) => void;
|
||||
noiseIntensity: number;
|
||||
onNoiseIntensityChange: (intensity: number) => void;
|
||||
onRegenerate: () => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export function GradientControls({
|
||||
paletteId,
|
||||
onPaletteChange,
|
||||
gradientCount,
|
||||
onGradientCountChange,
|
||||
noiseIntensity,
|
||||
onNoiseIntensityChange,
|
||||
onRegenerate,
|
||||
onExport,
|
||||
}: GradientControlsProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-6">
|
||||
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-900">
|
||||
Grain
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Separator.Root className="h-px bg-white/20 mx-6" />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Palette Selector */}
|
||||
<PaletteSelector
|
||||
selectedPaletteId={paletteId}
|
||||
onSelect={onPaletteChange}
|
||||
/>
|
||||
|
||||
<Separator.Root className="h-px bg-white/10" />
|
||||
|
||||
{/* Gradient Count Slider */}
|
||||
<SliderControl
|
||||
label="Gradients"
|
||||
value={gradientCount}
|
||||
onChange={onGradientCountChange}
|
||||
min={2}
|
||||
max={8}
|
||||
step={1}
|
||||
/>
|
||||
|
||||
{/* Noise Intensity Slider */}
|
||||
<SliderControl
|
||||
label="Noise"
|
||||
value={noiseIntensity}
|
||||
onChange={onNoiseIntensityChange}
|
||||
min={0}
|
||||
max={1.5}
|
||||
step={0.1}
|
||||
formatValue={(v) => `${Math.round(v * 100)}%`}
|
||||
/>
|
||||
|
||||
<Separator.Root className="h-px bg-white/10" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/PaletteSelector.tsx
Normal file
58
src/components/PaletteSelector.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { COLOR_PALETTES, type ColorPalette } from "../utils/palettes";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface PaletteSelectorProps {
|
||||
selectedPaletteId: string;
|
||||
onSelect: (paletteId: string) => void;
|
||||
}
|
||||
|
||||
export function PaletteSelector({ selectedPaletteId, onSelect }: PaletteSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
|
||||
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,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><feTurbulence></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;
|
||||
50
src/components/SliderControl.tsx
Normal file
50
src/components/SliderControl.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as Slider from "@radix-ui/react-slider";
|
||||
|
||||
interface SliderControlProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
export function SliderControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
formatValue = (v) => v.toString()
|
||||
}: SliderControlProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
|
||||
{label}
|
||||
</label>
|
||||
<span className="font-inter text-sm text-zinc-800">
|
||||
{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 flex-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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&family=Raleway:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono&display=swap");
|
||||
@import "@fontsource/space-grotesk/400.css";
|
||||
@import "@fontsource/space-grotesk/600.css";
|
||||
@import "@fontsource/space-grotesk/700.css";
|
||||
@import "@fontsource/inter/400.css";
|
||||
@import "@fontsource/inter/500.css";
|
||||
@import "@fontsource/inter/600.css";
|
||||
@import "@fontsource/roboto-mono/400.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-family-opensans: "Open Sans", sans-serif;
|
||||
--font-family-space-grotesk: "Space Grotesk", sans-serif;
|
||||
--font-family-inter: "Inter", sans-serif;
|
||||
--font-family-mono: "Roboto Mono", monospace;
|
||||
--font-family-raleway: "Raleway", sans-serif;
|
||||
--font-family-roboto: "Roboto";
|
||||
|
||||
--box-shadow-inner-md: inset 1px 4px 6px 0 rgb(0 0 0 / 0.1);
|
||||
--box-shadow-inner-md-2: inset 2px 2px 6px 0 rgb(0 0 0 / 0.15);
|
||||
@@ -60,3 +62,47 @@ body {
|
||||
pre.inline {
|
||||
@apply text-zinc-900;
|
||||
}
|
||||
|
||||
/* Glassmorphism utilities */
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(50px) saturate(100%);
|
||||
/* -webkit-backdrop-filter: blur(10px) saturate(80%); */
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px) saturate(60%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(60%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.glass-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(8px) saturate(60%);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(60%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(15px) saturate(50%);
|
||||
-webkit-backdrop-filter: blur(15px) saturate(50%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
113
src/index.tsx
113
src/index.tsx
@@ -1,71 +1,98 @@
|
||||
import { hydrate, prerender as ssr } from "preact-iso";
|
||||
import { useMemo, useState, useEffect } from "preact/hooks";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
import { useViewportSize } from "./utils/useViewportSize";
|
||||
import { useBooleanToggle } from "./utils/useBooleanToggle";
|
||||
import useBackground from "./utils/useBackground";
|
||||
import Post from "./components/Post";
|
||||
import { RefreshCw, Eye, EyeOff } from "lucide-preact";
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { GlassPanel } from "./components/GlassPanel";
|
||||
import { GradientControls } from "./components/GradientControls";
|
||||
import { ExportModal } from "./components/ExportModal";
|
||||
import { getPaletteById } from "./utils/palettes";
|
||||
import type { ExportData } from "./utils/exportGradient";
|
||||
|
||||
export function App() {
|
||||
const { width, height } = useViewportSize();
|
||||
|
||||
// Gradient state
|
||||
const [paletteId, setPaletteId] = useState("classic");
|
||||
const [gradientCount, setGradientCount] = useState(5);
|
||||
const [noiseIntensity, setNoiseIntensity] = useState(0.9);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
|
||||
const palette = getPaletteById(paletteId);
|
||||
|
||||
const { svg, backgrounds, regenerate } = useBackground({
|
||||
width,
|
||||
height,
|
||||
ratio: 0.4,
|
||||
paletteColors: palette.colors,
|
||||
gradientCount,
|
||||
noiseIntensity,
|
||||
});
|
||||
const [postHidden, toggleHidden] = useBooleanToggle(false);
|
||||
const [iconSpinning, toggleIconSpinning] = useBooleanToggle(false);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const gradientStyle = useMemo(() => {
|
||||
return {
|
||||
background: [`url("${svg}")`, ...backgrounds].join(", "),
|
||||
};
|
||||
}, [svg, backgrounds]);
|
||||
|
||||
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);
|
||||
const exportData: ExportData = useMemo(() => ({
|
||||
backgrounds,
|
||||
svg,
|
||||
noiseIntensity,
|
||||
}), [backgrounds, svg, noiseIntensity]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === "r" || e.key === "R") {
|
||||
e.preventDefault();
|
||||
regenerate();
|
||||
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>
|
||||
} else if (e.key === "e" || e.key === "E") {
|
||||
e.preventDefault();
|
||||
setExportModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyPress);
|
||||
return () => window.removeEventListener("keydown", handleKeyPress);
|
||||
}, [regenerate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Full-screen gradient background */}
|
||||
<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`}
|
||||
id="gradient-container"
|
||||
style={gradientStyle}
|
||||
className="w-full h-screen gradient text-zinc-800 overflow-clip"
|
||||
>
|
||||
<div className="bg-white overflow-y-auto">
|
||||
<Post />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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={() => setExportModalOpen(true)}
|
||||
/>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Export modal */}
|
||||
<ExportModal
|
||||
open={exportModalOpen}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
exportData={exportData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
86
src/utils/exportGradient.ts
Normal file
86
src/utils/exportGradient.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import html2canvas from "html2canvas";
|
||||
|
||||
export interface ExportData {
|
||||
backgrounds: string[];
|
||||
svg: string;
|
||||
noiseIntensity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current gradient as a PNG image
|
||||
*/
|
||||
export async function exportAsPNG(elementId: string = "gradient-container"): Promise<void> {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
throw new Error("Gradient element not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(element, {
|
||||
backgroundColor: null,
|
||||
scale: 2, // Higher quality
|
||||
logging: false,
|
||||
});
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `grain-gradient-${Date.now()}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to export PNG:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSS code for the current gradient
|
||||
*/
|
||||
export function generateCSS(data: ExportData): string {
|
||||
const { backgrounds, svg } = data;
|
||||
|
||||
const backgroundLayers = [`url("${svg}")`, ...backgrounds].join(",\n ");
|
||||
|
||||
return `/* Grain Gradient CSS */
|
||||
.gradient-background {
|
||||
background: ${backgroundLayers};
|
||||
filter: contrast(150%) brightness(90%);
|
||||
background-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* Additional overlay (optional) */
|
||||
.gradient-overlay {
|
||||
background: rgba(40, 40, 40, 0.5);
|
||||
background-blend-mode: overlay;
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy CSS code to clipboard
|
||||
*/
|
||||
export async function copyCSSToClipboard(css: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(css);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download CSS code as a file
|
||||
*/
|
||||
export function downloadCSS(css: string): void {
|
||||
const blob = new Blob([css], { type: "text/css" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `grain-gradient-${Date.now()}.css`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
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,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 {
|
||||
|
||||
Reference in New Issue
Block a user