feat(ui): add shared seeded paper gradients (#1288)

* feat(ui): add shared seeded paper gradients

Centralize the Paper mesh and grain wrappers so React and Solid apps can reuse the same deterministic seed-based visuals without repeating shader config. Add a standalone demo surface and update existing consumers so the shared package is easier to validate and evolve.

* fix(ui): resolve shared package from source

Point the shared UI package exports at source files so Next builds do not depend on a prebuilt dist directory. Add Next transpilation for @openwork/ui in Landing and Den Web so monorepo and Vercel builds resolve the package consistently.

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-01 17:33:42 -07:00
committed by Benjamin Shafii
parent d4592353e5
commit c1e12a41fb
32 changed files with 2059 additions and 652 deletions

View File

@@ -6,10 +6,13 @@
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 vite",
"dev:windows": "vite",
"prebuild": "pnpm --dir ../../packages/ui build",
"build": "vite build",
"dev:web": "OPENWORK_DEV_MODE=1 vite",
"prebuild:web": "pnpm --dir ../../packages/ui build",
"build:web": "vite build",
"preview": "vite preview",
"pretypecheck": "pnpm --dir ../../packages/ui build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:health": "node scripts/health.mjs",
"test:mention-send": "node scripts/mention-send.mjs",
@@ -32,6 +35,7 @@
"bump:set": "node scripts/bump-version.mjs --set"
},
"dependencies": {
"@openwork/ui": "workspace:*",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/language": "^6.11.0",

View File

@@ -11,7 +11,6 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@paper-design/shaders-react": "0.0.71",
"@vercel/blob": "^0.27.0",
"botid": "^1.5.11",
"jsonc-parser": "^3.3.1",

12
apps/ui-demo/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenWork UI Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
apps/ui-demo/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@openwork/ui-demo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "pnpm --dir ../../packages/ui build && vite --host 0.0.0.0 --port 3333 --strictPort",
"build": "pnpm --dir ../../packages/ui build && vite build",
"preview": "vite preview --host 0.0.0.0 --port 3333 --strictPort",
"typecheck": "pnpm --dir ../../packages/ui build && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@openwork/ui": "workspace:*",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^5.0.4",
"typescript": "^5.9.3",
"vite": "^7.1.12"
}
}

216
apps/ui-demo/src/app.tsx Normal file
View File

@@ -0,0 +1,216 @@
import {
PaperGrainGradient,
PaperMeshGradient,
getSeededPaperGrainGradientConfig,
getSeededPaperMeshGradientConfig,
} from "@openwork/ui/react"
import { useMemo, useState } from "react"
const sampleIds = [
"om_01kmhbscaze02vp04ykqa4tcsb",
"om_01kmhbscazf4cjf1bssx6v9q9",
"ow_01kmj2wc68r1zk4n8v7j6v1n2k",
]
export function App() {
const [seed, setSeed] = useState(sampleIds[0])
const normalizedSeed = seed.trim() || sampleIds[0]
const parsedSeed = parseTypeId(normalizedSeed)
const meshConfig = useMemo(() => getSeededPaperMeshGradientConfig(normalizedSeed), [normalizedSeed])
const grainConfig = useMemo(() => getSeededPaperGrainGradientConfig(normalizedSeed), [normalizedSeed])
return (
<main className="app-shell">
<div className="ambient ambient-a" />
<div className="ambient ambient-b" />
<section className="hero-card panel">
<div className="hero-copy">
<span className="eyebrow">OpenWork UI demo</span>
<h1>Seeded Paper gradients on their own dev surface</h1>
<p>
Type a TypeID-like string, inspect the deterministic values derived from it, and preview
the gradients that `@openwork/ui/react` will render anywhere else in the repo.
</p>
</div>
<div className="rule-card">
<span className="eyebrow muted">Deterministic</span>
<strong>Same seed, same result.</strong>
<p>Useful for stable identity-driven art direction across apps.</p>
</div>
</section>
<section className="controls-grid">
<div className="panel input-panel">
<label className="eyebrow muted" htmlFor="seed-id">
Seed id
</label>
<input
id="seed-id"
className="seed-input"
type="text"
value={seed}
onChange={(event) => setSeed(event.target.value)}
spellCheck={false}
/>
<div className="sample-list">
{sampleIds.map((sampleId) => (
<button
key={sampleId}
type="button"
className={sampleId === normalizedSeed ? "sample-chip active" : "sample-chip"}
onClick={() => setSeed(sampleId)}
>
{sampleId}
</button>
))}
</div>
</div>
<div className="panel seed-meta-grid">
<SeedMeta label="prefix" value={parsedSeed.prefix ?? "-"} />
<SeedMeta label="suffix" value={parsedSeed.suffix ?? "-"} />
<SeedMeta label="suffix first 5" value={parsedSeed.suffixAnchor ?? "-"} />
<SeedMeta label="suffix tail" value={parsedSeed.suffixTail ?? "-"} />
</div>
</section>
<section className="preview-grid">
<GradientCard
title="Mesh gradient"
subtitle="Shared mesh defaults plus seeded color and motion variation"
colors={meshConfig.colors}
config={meshConfig}
surface={<PaperMeshGradient seed={normalizedSeed} className="gradient-fill" />}
/>
<GradientCard
title="Grain gradient"
subtitle="Shared grain defaults plus seeded background, shape, and values"
colors={[grainConfig.colorBack, ...grainConfig.colors]}
config={grainConfig}
surface={<PaperGrainGradient seed={normalizedSeed} className="gradient-fill" />}
/>
</section>
<section className="footer-grid">
<div className="panel">
<span className="eyebrow muted">Determinism check</span>
<div className="mini-grid">
<MiniPreview title="Mesh A">
<PaperMeshGradient seed={normalizedSeed} className="gradient-fill" />
</MiniPreview>
<MiniPreview title="Mesh B">
<PaperMeshGradient seed={normalizedSeed} className="gradient-fill" />
</MiniPreview>
</div>
<p className="support-copy">
These two cards use the same seed and should always match.
</p>
</div>
<div className="panel code-panel">
<span className="eyebrow muted">Import paths</span>
<div className="pill-stack">
<code className="import-pill">@openwork/ui/react</code>
<code className="import-pill">@openwork/ui/solid</code>
</div>
<pre>{`import { PaperMeshGradient, PaperGrainGradient } from "@openwork/ui/react"
<PaperMeshGradient seed="${normalizedSeed}" />
<PaperGrainGradient seed="${normalizedSeed}" />`}</pre>
</div>
</section>
</main>
)
}
function GradientCard({
title,
subtitle,
colors,
config,
surface,
}: {
title: string
subtitle: string
colors: string[]
config: Record<string, unknown>
surface: React.ReactNode
}) {
return (
<article className="panel preview-card">
<div className="gradient-surface">
{surface}
<div className="surface-overlay" />
<div className="surface-copy">
<span className="eyebrow on-dark">@openwork/ui/react</span>
<h2>{title}</h2>
<p>{subtitle}</p>
</div>
</div>
<div className="details-stack">
<div>
<span className="eyebrow muted">Colors</span>
<div className="swatch-list">
{colors.map((color) => (
<div key={color} className="swatch-pill">
<span className="swatch-dot" style={{ backgroundColor: color }} />
<code>{color}</code>
</div>
))}
</div>
</div>
<div>
<span className="eyebrow muted">Calculated values</span>
<pre>{JSON.stringify(config, null, 2)}</pre>
</div>
</div>
</article>
)
}
function MiniPreview({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<span className="eyebrow muted">{title}</span>
<div className="mini-surface">{children}</div>
</div>
)
}
function SeedMeta({ label, value }: { label: string; value: string }) {
return (
<div className="seed-meta-card">
<span className="eyebrow muted">{label}</span>
<code>{value}</code>
</div>
)
}
function parseTypeId(value: string) {
const separatorIndex = value.indexOf("_")
if (separatorIndex === -1) {
return {
prefix: null,
suffix: value,
suffixAnchor: value.slice(0, 5) || null,
suffixTail: value.slice(5) || null,
}
}
const prefix = value.slice(0, separatorIndex) || null
const suffix = value.slice(separatorIndex + 1) || null
return {
prefix,
suffix,
suffixAnchor: suffix?.slice(0, 5) || null,
suffixTail: suffix?.slice(5) || null,
}
}

10
apps/ui-demo/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { App } from "./app"
import "./styles.css"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

343
apps/ui-demo/src/styles.css Normal file
View File

@@ -0,0 +1,343 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Inter", system-ui, sans-serif;
background:
radial-gradient(circle at top left, rgba(57, 181, 74, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(39, 98, 255, 0.14), transparent 30%),
linear-gradient(180deg, #f6f1e8 0%, #efe7d7 100%);
color: #1f2c2b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input,
textarea,
select {
font: inherit;
}
code,
pre,
.sample-chip,
.seed-input {
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
}
.app-shell {
position: relative;
min-height: 100vh;
padding: 32px;
overflow: hidden;
}
.ambient {
position: fixed;
pointer-events: none;
border-radius: 999px;
filter: blur(70px);
opacity: 0.45;
}
.ambient-a {
top: -120px;
left: -80px;
width: 320px;
height: 320px;
background: rgba(76, 175, 80, 0.22);
}
.ambient-b {
right: -80px;
bottom: 80px;
width: 280px;
height: 280px;
background: rgba(59, 130, 246, 0.2);
}
.panel {
position: relative;
border: 1px solid rgba(24, 30, 28, 0.08);
background: rgba(255, 251, 245, 0.82);
backdrop-filter: blur(18px);
border-radius: 28px;
box-shadow: 0 24px 80px -48px rgba(29, 24, 17, 0.45);
}
.hero-card {
display: grid;
gap: 24px;
grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr);
padding: 32px;
}
.hero-copy h1 {
margin: 10px 0 0;
font-size: clamp(2.4rem, 6vw, 4.5rem);
line-height: 0.92;
letter-spacing: -0.08em;
}
.hero-copy p,
.rule-card p,
.support-copy,
.surface-copy p {
margin: 0;
color: #516160;
line-height: 1.7;
}
.hero-copy p {
margin-top: 18px;
max-width: 62ch;
}
.rule-card {
align-self: end;
padding: 20px;
border-radius: 24px;
background: #16201f;
color: #f0f7f3;
}
.rule-card p {
margin-top: 8px;
color: rgba(240, 247, 243, 0.74);
}
.eyebrow {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.muted {
color: #6d7a79;
}
.on-dark {
color: rgba(255, 255, 255, 0.68);
}
.controls-grid,
.footer-grid {
display: grid;
gap: 24px;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.95fr);
margin-top: 24px;
}
.input-panel,
.seed-meta-grid,
.code-panel {
padding: 24px;
}
.seed-input {
width: 100%;
margin-top: 10px;
padding: 15px 16px;
border-radius: 20px;
border: 1px solid rgba(22, 32, 31, 0.12);
background: rgba(255, 255, 255, 0.92);
color: #182321;
font-size: 0.92rem;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.seed-input:focus {
border-color: #287d75;
box-shadow: 0 0 0 5px rgba(40, 125, 117, 0.12);
}
.sample-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
.sample-chip {
border: 1px solid rgba(22, 32, 31, 0.1);
background: rgba(255, 255, 255, 0.75);
color: #425251;
border-radius: 999px;
padding: 9px 14px;
cursor: pointer;
}
.sample-chip.active {
background: #1f6978;
color: white;
border-color: #1f6978;
}
.seed-meta-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
background: #16201f;
}
.seed-meta-card {
padding: 18px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.seed-meta-card code {
display: block;
margin-top: 8px;
color: #f4fbf7;
word-break: break-all;
line-height: 1.6;
}
.preview-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 24px;
}
.preview-card {
overflow: hidden;
}
.gradient-surface {
position: relative;
min-height: 340px;
background: #101818;
}
.gradient-fill {
position: absolute;
inset: 0;
}
.surface-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(10, 18, 18, 0.08), rgba(10, 18, 18, 0.34));
}
.surface-copy {
position: absolute;
inset-inline: 0;
bottom: 0;
padding: 24px;
color: white;
}
.surface-copy h2 {
margin: 10px 0 0;
font-size: 2rem;
letter-spacing: -0.05em;
}
.surface-copy p {
margin-top: 10px;
color: rgba(255, 255, 255, 0.78);
}
.details-stack {
display: grid;
gap: 20px;
padding: 24px;
}
.swatch-list,
.pill-stack {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.swatch-pill,
.import-pill {
display: inline-flex;
align-items: center;
gap: 10px;
border-radius: 999px;
border: 1px solid rgba(22, 32, 31, 0.08);
background: rgba(248, 243, 235, 0.9);
padding: 10px 14px;
}
.swatch-dot {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.12);
}
pre {
overflow-x: auto;
margin: 14px 0 0;
border-radius: 22px;
background: #16201f;
color: #dcebe4;
padding: 18px;
font-size: 0.8rem;
line-height: 1.7;
}
.mini-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 16px;
}
.mini-surface {
position: relative;
min-height: 180px;
margin-top: 8px;
overflow: hidden;
border-radius: 22px;
background: #111918;
}
.support-copy {
margin-top: 16px;
}
@media (max-width: 980px) {
.hero-card,
.controls-grid,
.preview-grid,
.footer-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-shell {
padding: 18px;
}
.hero-card,
.input-panel,
.seed-meta-grid,
.code-panel,
.details-stack {
padding: 18px;
}
.seed-meta-grid,
.mini-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 3333,
strictPort: true,
},
preview: {
host: "0.0.0.0",
port: 3333,
strictPort: true,
},
build: {
target: "es2022",
},
})

View File

@@ -1,6 +1,7 @@
"use client";
import { Dithering, MeshGradient } from "@paper-design/shaders-react";
import { PaperMeshGradient } from "@openwork/ui/react";
import { Dithering } from "@paper-design/shaders-react";
import { ArrowRight, CheckCircle2 } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@@ -184,7 +185,7 @@ export function AuthScreen() {
colorFront="#FEFEFE"
style={{ backgroundColor: "#142033", width: "100%", height: "100%" }}
>
<MeshGradient
<PaperMeshGradient
speed={0.1}
distortion={0.8}
swirl={0.1}

View File

@@ -16,7 +16,8 @@ import {
Plus,
RefreshCw,
} from "lucide-react";
import { Dithering, MeshGradient } from "@paper-design/shaders-react";
import { PaperMeshGradient } from "@openwork/ui/react";
import { Dithering } from "@paper-design/shaders-react";
import {
OPENWORK_APP_CONNECT_BASE_URL,
buildOpenworkAppConnectUrl,
@@ -406,7 +407,7 @@ export function BackgroundAgentsScreen() {
colorFront="#FEFEFE"
style={{ backgroundColor: "#23301C", width: "100%", height: "100%" }}
>
<MeshGradient
<PaperMeshGradient
speed={0}
distortion={0.8}
swirl={0.1}

View File

@@ -1,7 +1,8 @@
"use client";
import { Cpu } from "lucide-react";
import { Dithering, MeshGradient } from "@paper-design/shaders-react";
import { PaperMeshGradient } from "@openwork/ui/react";
import { Dithering } from "@paper-design/shaders-react";
const comingSoonItems = [
"Standardize provider access across your team.",
@@ -25,7 +26,7 @@ export function CustomLlmProvidersScreen() {
colorFront="#FEFEFE"
style={{ backgroundColor: "#1C2A30", width: "100%", height: "100%" }}
>
<MeshGradient
<PaperMeshGradient
speed={0.1}
distortion={0.8}
swirl={0.1}

View File

@@ -4,6 +4,7 @@ const path = require("path");
const nextConfig = {
reactStrictMode: true,
skipTrailingSlashRedirect: true,
transpilePackages: ["@openwork/ui"],
outputFileTracingRoot: path.join(__dirname, "../../.."),
};

View File

@@ -5,12 +5,14 @@
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= next dev --hostname 0.0.0.0 --port 3005",
"dev:local": "sh -lc 'OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= next dev --hostname 0.0.0.0 --port ${DEN_WEB_PORT:-3005}'",
"prebuild": "pnpm --dir ../../../packages/ui build",
"build": "next build",
"start": "next start --hostname 0.0.0.0 --port 3005",
"lint": "next lint"
},
"dependencies": {
"@paper-design/shaders-react": "0.0.71",
"@openwork/ui": "workspace:*",
"@paper-design/shaders-react": "0.0.72",
"lucide-react": "^0.577.0",
"next": "16.2.1",
"react": "19.2.4",

View File

@@ -1,9 +1,9 @@
"use client";
import { GrainGradient } from "@paper-design/shaders-react";
import { useEffect, useRef, useState } from "react";
import { PaperGrainGradient } from "@openwork/ui/react";
type Props = {
seed?: string;
colors: string[];
colorBack: string;
softness: number;
@@ -15,43 +15,17 @@ type Props = {
};
export function ResponsiveGrain(props: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height
});
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
return (
<div
ref={containerRef}
<PaperGrainGradient
className={`absolute inset-0 overflow-hidden ${props.className || ""}`}
>
{dimensions.width > 0 && dimensions.height > 0 ? (
<GrainGradient
width={dimensions.width}
height={dimensions.height}
colors={props.colors}
colorBack={props.colorBack}
softness={props.softness}
intensity={props.intensity}
noise={props.noise}
shape={props.shape}
speed={props.speed}
/>
) : null}
</div>
seed={props.seed}
colors={props.colors}
colorBack={props.colorBack}
softness={props.softness}
intensity={props.intensity}
noise={props.noise}
shape={props.shape}
speed={props.speed}
/>
);
}

View File

@@ -5,6 +5,7 @@ const mintlifyOrigin = "https://differentai.mintlify.dev";
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@openwork/ui"],
async rewrites() {
return [
{

View File

@@ -4,12 +4,13 @@
"version": "0.0.0",
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 next dev --hostname 0.0.0.0",
"prebuild": "pnpm --dir ../../../packages/ui build",
"build": "next build",
"start": "next start --hostname 0.0.0.0",
"lint": "next lint"
},
"dependencies": {
"@paper-design/shaders-react": "0.0.71",
"@openwork/ui": "workspace:*",
"botid": "^1.5.11",
"framer-motion": "^12.35.1",
"lucide-react": "^0.577.0",

View File

@@ -7,6 +7,7 @@
"dev:windows": ".\\scripts\\dev-windows.cmd",
"dev:windows:x64": ".\\scripts\\dev-windows.cmd x64",
"dev:ui": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork/app dev",
"dev:ui-demo": "pnpm --filter @openwork/ui-demo dev",
"dev:story": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork/story-book dev",
"dev:web": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork-ee/den-web dev",
"dev:web-local": "pnpm dev:den-local",

39
packages/ui/README.md Normal file
View File

@@ -0,0 +1,39 @@
# @openwork/ui
Shared UI primitives for OpenWork apps.
This package intentionally ships two framework-specific entrypoints:
- `@openwork/ui/react` for React apps like `ee/apps/den-web`
- `@openwork/ui/solid` for Solid apps like `apps/app`
The public API should stay aligned across both entrypoints. If you add a new component, add both implementations in the same task unless there is a documented blocker.
## Paper components
The first shared components live under the `paper` namespace and wrap Paper Design shaders with OpenWork-specific defaults and deterministic seed support.
Current components:
- `PaperMeshGradient`
- `PaperGrainGradient`
Both accept a `seed` prop. Pass a TypeID-like string such as `om_01kmhbscaze02vp04ykqa4tcsb` and the component will deterministically derive colors and shader params from it. The same seed always produces the same result.
Explicit props still work and override the seeded values, so the merge order is:
1. OpenWork defaults
2. Seed-derived values from `seed`
3. Explicit props passed by the caller
## Layout convention
These components default to `fill={true}`, which means they render at `width: 100%` and `height: 100%`. Put them inside a sized container and they will fill it without needing manual width or height props.
## Agent notes
- Shared seed logic lives in `src/common/paper.ts`
- React wrappers live in `src/react/paper/*`
- Solid wrappers live in `src/solid/paper/*`
- Keep the framework prop names aligned unless there is a hard runtime mismatch
- Prefer extending the existing seed helpers instead of inventing per-app one-off shader configs

38
packages/ui/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "@openwork/ui",
"private": true,
"type": "module",
"exports": {
"./react": {
"types": "./src/react/index.ts",
"development": "./src/react/index.ts",
"default": "./src/react/index.ts"
},
"./solid": {
"types": "./src/solid/index.ts",
"development": "./src/solid/index.ts",
"default": "./src/solid/index.ts"
}
},
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "tsup"
},
"dependencies": {
"@paper-design/shaders": "0.0.72",
"@paper-design/shaders-react": "0.0.72"
},
"peerDependencies": {
"react": "^18 || ^19",
"solid-js": "^1.9.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"tsup": "^8.5.0",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,416 @@
import type {
GrainGradientParams,
GrainGradientShape,
MeshGradientParams,
} from "@paper-design/shaders"
export type PaperMeshGradientConfig = Required<
Pick<
MeshGradientParams,
"colors" | "distortion" | "swirl" | "grainMixer" | "grainOverlay" | "speed" | "frame"
>
>
export type PaperGrainGradientConfig = Required<
Pick<
GrainGradientParams,
"colorBack" | "colors" | "softness" | "intensity" | "noise" | "shape" | "speed" | "frame"
>
>
export type SeededPaperOption = {
seed?: string
}
export const paperMeshGradientDefaults: PaperMeshGradientConfig = {
colors: ["#e0eaff", "#241d9a", "#f75092", "#9f50d3"],
distortion: 0.8,
swirl: 0.1,
grainMixer: 0,
grainOverlay: 0,
speed: 0.1,
frame: 0,
}
export const paperGrainGradientDefaults: PaperGrainGradientConfig = {
colors: ["#7300ff", "#eba8ff", "#00bfff", "#2b00ff"],
colorBack: "#000000",
softness: 0.5,
intensity: 0.5,
noise: 0.25,
shape: "ripple",
speed: 0.4,
frame: 0,
}
const grainShapes: GrainGradientShape[] = [
"corners",
"wave",
"dots",
"truchet",
"ripple",
"blob",
"sphere",
]
const meshPaletteFamilies = [
["#e0eaff", "#241d9a", "#f75092", "#9f50d3"],
["#ddfff5", "#006c67", "#35d8c0", "#8cff7a"],
["#ffe5c2", "#8a2500", "#ff7b39", "#ffd166"],
["#f5f7ff", "#0d1b52", "#3f8cff", "#00c2ff"],
["#fff2f2", "#6f1237", "#ff4d6d", "#ffb703"],
["#f0ffe1", "#254d00", "#8cc63f", "#00a76f"],
["#f5edff", "#44206b", "#b5179e", "#7209b7"],
["#f4f1ea", "#3a2f1f", "#927c55", "#d0c2a8"],
]
const grainPaletteFamilies = [
["#7300ff", "#eba8ff", "#00bfff", "#2b00ff"],
["#0df2c1", "#0b7cff", "#74efff", "#1a2cff"],
["#ff7a18", "#ffd166", "#ff4d6d", "#5f0f40"],
["#8dff6a", "#1f7a1f", "#d7ff70", "#00c48c"],
["#f6a6ff", "#7027c9", "#ff66c4", "#20115b"],
["#b9ecff", "#006494", "#00a6a6", "#072ac8"],
["#f7f0d6", "#8c5e34", "#d68c45", "#4e342e"],
["#ffd9f5", "#ff006e", "#8338ec", "#3a0ca3"],
]
const paletteModes = [
{
hueOffsets: [0, 22, 182, 238],
saturations: [0.92, 0.7, 0.84, 0.74],
lightnesses: [0.82, 0.28, 0.6, 0.5],
},
{
hueOffsets: [0, 118, 242, 304],
saturations: [0.88, 0.76, 0.82, 0.7],
lightnesses: [0.8, 0.42, 0.58, 0.48],
},
{
hueOffsets: [0, 44, 156, 214],
saturations: [0.94, 0.78, 0.86, 0.72],
lightnesses: [0.78, 0.4, 0.6, 0.46],
},
{
hueOffsets: [0, 76, 184, 326],
saturations: [0.86, 0.8, 0.78, 0.76],
lightnesses: [0.82, 0.52, 0.42, 0.58],
},
{
hueOffsets: [0, 140, 196, 224],
saturations: [0.84, 0.7, 0.76, 0.88],
lightnesses: [0.86, 0.46, 0.36, 0.54],
},
{
hueOffsets: [0, 162, 212, 342],
saturations: [0.9, 0.72, 0.8, 0.82],
lightnesses: [0.8, 0.38, 0.52, 0.56],
},
]
type MeshGradientOverrides = SeededPaperOption & Partial<PaperMeshGradientConfig>
type GrainGradientOverrides = SeededPaperOption & Partial<PaperGrainGradientConfig>
export function getSeededPaperMeshGradientConfig(seed: string): PaperMeshGradientConfig {
const random = createRandom(seed, "mesh")
return {
colors: createSeededPalette(paperMeshGradientDefaults.colors, seed, "mesh-colors", {
families: meshPaletteFamilies,
hueShift: 42,
saturationShift: 0.18,
lightnessShift: 0.14,
baseBlend: [0.08, 0.2],
}),
distortion: roundTo(clamp(0.58 + random() * 0.32, 0, 1), 3),
swirl: roundTo(clamp(0.03 + random() * 0.28, 0, 1), 3),
grainMixer: roundTo(clamp(random() * 0.18, 0, 1), 3),
grainOverlay: roundTo(clamp(random() * 0.12, 0, 1), 3),
speed: roundTo(0.05 + random() * 0.11, 3),
frame: Math.round(random() * 240000),
}
}
export function getSeededPaperGrainGradientConfig(seed: string): PaperGrainGradientConfig {
const random = createRandom(seed, "grain")
const colors = createSeededPalette(paperGrainGradientDefaults.colors, seed, "grain-colors", {
families: grainPaletteFamilies,
hueShift: 58,
saturationShift: 0.22,
lightnessShift: 0.18,
baseBlend: [0.04, 0.14],
})
const anchorColor = colors[Math.floor(random() * colors.length)] ?? colors[0]
return {
colors,
colorBack: createSeededBackground(anchorColor, seed, "grain-background"),
softness: roundTo(clamp(0.22 + random() * 0.56, 0, 1), 3),
intensity: roundTo(clamp(0.2 + random() * 0.6, 0, 1), 3),
noise: roundTo(clamp(0.12 + random() * 0.34, 0, 1), 3),
shape: grainShapes[Math.floor(random() * grainShapes.length)] ?? paperGrainGradientDefaults.shape,
speed: roundTo(0.2 + random() * 0.6, 3),
frame: Math.round(random() * 320000),
}
}
export function resolvePaperMeshGradientConfig(
options: MeshGradientOverrides = {},
): PaperMeshGradientConfig {
const seeded = options.seed ? getSeededPaperMeshGradientConfig(options.seed) : paperMeshGradientDefaults
return {
colors: options.colors ?? seeded.colors,
distortion: options.distortion ?? seeded.distortion,
swirl: options.swirl ?? seeded.swirl,
grainMixer: options.grainMixer ?? seeded.grainMixer,
grainOverlay: options.grainOverlay ?? seeded.grainOverlay,
speed: options.speed ?? seeded.speed,
frame: options.frame ?? seeded.frame,
}
}
export function resolvePaperGrainGradientConfig(
options: GrainGradientOverrides = {},
): PaperGrainGradientConfig {
const seeded = options.seed ? getSeededPaperGrainGradientConfig(options.seed) : paperGrainGradientDefaults
return {
colors: options.colors ?? seeded.colors,
colorBack: options.colorBack ?? seeded.colorBack,
softness: options.softness ?? seeded.softness,
intensity: options.intensity ?? seeded.intensity,
noise: options.noise ?? seeded.noise,
shape: options.shape ?? seeded.shape,
speed: options.speed ?? seeded.speed,
frame: options.frame ?? seeded.frame,
}
}
function buildSeedSource(seed: string) {
const trimmedSeed = seed.trim()
const separatorIndex = trimmedSeed.indexOf("_")
if (separatorIndex === -1) {
return trimmedSeed
}
const prefix = trimmedSeed.slice(0, separatorIndex)
const suffix = trimmedSeed.slice(separatorIndex + 1)
const suffixTail = suffix.slice(5) || suffix
return `${trimmedSeed}|${prefix}|${suffix}|${suffixTail}`
}
function createSeededPalette(
baseColors: string[],
seed: string,
namespace: string,
options: {
families: string[][]
hueShift: number
saturationShift: number
lightnessShift: number
baseBlend: [number, number]
},
) {
const familyRandom = createRandom(seed, `${namespace}:family`)
const primaryIndex = Math.floor(familyRandom() * options.families.length)
const secondaryOffset = 1 + Math.floor(familyRandom() * (options.families.length - 1))
const secondaryIndex = (primaryIndex + secondaryOffset) % options.families.length
const primary = options.families[primaryIndex] ?? baseColors
const secondary = options.families[secondaryIndex] ?? [...baseColors].reverse()
const primaryShift = Math.floor(familyRandom() * primary.length)
const secondaryShift = Math.floor(familyRandom() * secondary.length)
const paletteMode = paletteModes[Math.floor(familyRandom() * paletteModes.length)] ?? paletteModes[0]
const baseHue = familyRandom() * 360
return baseColors.map((color, index) => {
const random = createRandom(seed, `${namespace}:${index}`)
const primaryColor = primary[(index + primaryShift) % primary.length] ?? color
const secondaryColor = secondary[(index + secondaryShift) % secondary.length] ?? primaryColor
const proceduralColor = hslToHex(
(baseHue + paletteMode.hueOffsets[index % paletteMode.hueOffsets.length] + (random() * 2 - 1) * 18 + 360) % 360,
clamp(paletteMode.saturations[index % paletteMode.saturations.length] + (random() * 2 - 1) * 0.08, 0, 1),
clamp(paletteMode.lightnesses[index % paletteMode.lightnesses.length] + (random() * 2 - 1) * 0.08, 0, 1),
)
const mixedFamilyColor = mixHexColors(primaryColor, secondaryColor, 0.18 + random() * 0.64)
const remixedFamilyColor = mixHexColors(
mixedFamilyColor,
primary[(index + secondaryShift + 1) % primary.length] ?? mixedFamilyColor,
random() * 0.32,
)
const proceduralFamilyColor = mixHexColors(proceduralColor, remixedFamilyColor, 0.22 + random() * 0.34)
const [minBaseBlend, maxBaseBlend] = options.baseBlend
const blendedBaseColor = mixHexColors(
proceduralFamilyColor,
color,
minBaseBlend + random() * (maxBaseBlend - minBaseBlend),
)
return adjustHexColor(blendedBaseColor, {
hueShift: (random() * 2 - 1) * options.hueShift + (random() * 2 - 1) * 14,
saturationShift: (random() * 2 - 1) * options.saturationShift + 0.06,
lightnessShift: (random() * 2 - 1) * options.lightnessShift,
})
})
}
function createSeededBackground(baseColor: string, seed: string, namespace: string) {
const [red, green, blue] = hexToRgb(baseColor)
const [hue] = rgbToHsl(red, green, blue)
const random = createRandom(seed, namespace)
return hslToHex(
hue,
clamp(0.18 + random() * 0.18, 0, 1),
clamp(0.03 + random() * 0.09, 0, 1),
)
}
function adjustHexColor(
hex: string,
adjustments: { hueShift: number; saturationShift: number; lightnessShift: number },
) {
const [red, green, blue] = hexToRgb(hex)
const [hue, saturation, lightness] = rgbToHsl(red, green, blue)
return hslToHex(
(hue + adjustments.hueShift + 360) % 360,
clamp(saturation + adjustments.saturationShift, 0, 1),
clamp(lightness + adjustments.lightnessShift, 0, 1),
)
}
function mixHexColors(colorA: string, colorB: string, amount: number) {
const [redA, greenA, blueA] = hexToRgb(colorA)
const [redB, greenB, blueB] = hexToRgb(colorB)
const mixAmount = clamp(amount, 0, 1)
return rgbToHex(
Math.round(redA + (redB - redA) * mixAmount),
Math.round(greenA + (greenB - greenA) * mixAmount),
Math.round(blueA + (blueB - blueA) * mixAmount),
)
}
function createRandom(seed: string, namespace: string) {
return mulberry32(hashString(`${buildSeedSource(seed)}::${namespace}`))
}
function hashString(input: string) {
let hash = 2166136261
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
function mulberry32(seed: number) {
return function nextRandom() {
let value = seed += 0x6d2b79f5
value = Math.imul(value ^ (value >>> 15), value | 1)
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
}
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function roundTo(value: number, precision: number) {
const power = 10 ** precision
return Math.round(value * power) / power
}
function hexToRgb(hex: string): [number, number, number] {
const normalized = hex.replace(/^#/, "")
const expanded = normalized.length === 3
? normalized.split("").map((part) => `${part}${part}`).join("")
: normalized
if (expanded.length !== 6) {
throw new Error(`Unsupported hex color: ${hex}`)
}
const value = Number.parseInt(expanded, 16)
return [
(value >> 16) & 255,
(value >> 8) & 255,
value & 255,
]
}
function rgbToHsl(red: number, green: number, blue: number): [number, number, number] {
const normalizedRed = red / 255
const normalizedGreen = green / 255
const normalizedBlue = blue / 255
const max = Math.max(normalizedRed, normalizedGreen, normalizedBlue)
const min = Math.min(normalizedRed, normalizedGreen, normalizedBlue)
const lightness = (max + min) / 2
if (max === min) {
return [0, 0, lightness]
}
const delta = max - min
const saturation = lightness > 0.5
? delta / (2 - max - min)
: delta / (max + min)
let hue = 0
switch (max) {
case normalizedRed:
hue = (normalizedGreen - normalizedBlue) / delta + (normalizedGreen < normalizedBlue ? 6 : 0)
break
case normalizedGreen:
hue = (normalizedBlue - normalizedRed) / delta + 2
break
default:
hue = (normalizedRed - normalizedGreen) / delta + 4
break
}
return [hue * 60, saturation, lightness]
}
function hslToHex(hue: number, saturation: number, lightness: number) {
if (saturation === 0) {
const value = Math.round(lightness * 255)
return rgbToHex(value, value, value)
}
const hueToRgb = (p: number, q: number, t: number) => {
let normalizedT = t
if (normalizedT < 0) normalizedT += 1
if (normalizedT > 1) normalizedT -= 1
if (normalizedT < 1 / 6) return p + (q - p) * 6 * normalizedT
if (normalizedT < 1 / 2) return q
if (normalizedT < 2 / 3) return p + (q - p) * (2 / 3 - normalizedT) * 6
return p
}
const normalizedHue = hue / 360
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation
const p = 2 * lightness - q
const red = hueToRgb(p, q, normalizedHue + 1 / 3)
const green = hueToRgb(p, q, normalizedHue)
const blue = hueToRgb(p, q, normalizedHue - 1 / 3)
return rgbToHex(Math.round(red * 255), Math.round(green * 255), Math.round(blue * 255))
}
function rgbToHex(red: number, green: number, blue: number) {
return `#${[red, green, blue]
.map((value) => value.toString(16).padStart(2, "0"))
.join("")}`
}

View File

@@ -0,0 +1,17 @@
export {
getSeededPaperGrainGradientConfig,
getSeededPaperMeshGradientConfig,
paperGrainGradientDefaults,
paperMeshGradientDefaults,
resolvePaperGrainGradientConfig,
resolvePaperMeshGradientConfig,
} from "../common/paper"
export type {
PaperGrainGradientConfig,
PaperMeshGradientConfig,
SeededPaperOption,
} from "../common/paper"
export { PaperGrainGradient } from "./paper/grain-gradient"
export type { PaperGrainGradientProps } from "./paper/grain-gradient"
export { PaperMeshGradient } from "./paper/mesh-gradient"
export type { PaperMeshGradientProps } from "./paper/mesh-gradient"

View File

@@ -0,0 +1,101 @@
"use client"
import {
defaultObjectSizing,
defaultPatternSizing,
type GrainGradientShape,
} from "@paper-design/shaders"
import { GrainGradient, type GrainGradientProps } from "@paper-design/shaders-react"
import { resolvePaperGrainGradientConfig } from "../../common/paper"
export interface PaperGrainGradientProps
extends Omit<
GrainGradientProps,
"colorBack" | "colors" | "softness" | "intensity" | "noise" | "shape" | "speed" | "frame"
> {
seed?: string
fill?: boolean
colorBack?: string
colors?: string[]
softness?: number
intensity?: number
noise?: number
shape?: GrainGradientProps["shape"]
speed?: number
frame?: number
}
export function PaperGrainGradient({
seed,
fill = true,
colorBack,
colors,
softness,
intensity,
noise,
shape,
speed,
frame,
fit,
rotation,
scale,
originX,
originY,
offsetX,
offsetY,
worldWidth,
worldHeight,
width,
height,
...props
}: PaperGrainGradientProps) {
const resolved = resolvePaperGrainGradientConfig({
seed,
colorBack,
colors,
softness,
intensity,
noise,
shape,
speed,
frame,
})
const sizingDefaults = getSizingDefaults(resolved.shape)
return (
<GrainGradient
{...props}
width={width ?? (fill ? "100%" : undefined)}
height={height ?? (fill ? "100%" : undefined)}
fit={fit ?? sizingDefaults.fit}
rotation={rotation ?? sizingDefaults.rotation}
scale={scale ?? sizingDefaults.scale}
originX={originX ?? sizingDefaults.originX}
originY={originY ?? sizingDefaults.originY}
offsetX={offsetX ?? sizingDefaults.offsetX}
offsetY={offsetY ?? sizingDefaults.offsetY}
worldWidth={worldWidth ?? sizingDefaults.worldWidth}
worldHeight={worldHeight ?? sizingDefaults.worldHeight}
colorBack={resolved.colorBack}
colors={resolved.colors}
softness={resolved.softness}
intensity={resolved.intensity}
noise={resolved.noise}
shape={resolved.shape}
speed={resolved.speed}
frame={resolved.frame}
/>
)
}
function getSizingDefaults(shape: GrainGradientShape) {
switch (shape) {
case "wave":
case "dots":
case "truchet":
return defaultPatternSizing
default:
return defaultObjectSizing
}
}

View File

@@ -0,0 +1,61 @@
"use client"
import { MeshGradient, type MeshGradientProps } from "@paper-design/shaders-react"
import { resolvePaperMeshGradientConfig } from "../../common/paper"
export interface PaperMeshGradientProps
extends Omit<
MeshGradientProps,
"colors" | "distortion" | "swirl" | "grainMixer" | "grainOverlay" | "speed" | "frame"
> {
seed?: string
fill?: boolean
colors?: string[]
distortion?: number
swirl?: number
grainMixer?: number
grainOverlay?: number
speed?: number
frame?: number
}
export function PaperMeshGradient({
seed,
fill = true,
colors,
distortion,
swirl,
grainMixer,
grainOverlay,
speed,
frame,
width,
height,
...props
}: PaperMeshGradientProps) {
const resolved = resolvePaperMeshGradientConfig({
seed,
colors,
distortion,
swirl,
grainMixer,
grainOverlay,
speed,
frame,
})
return (
<MeshGradient
{...props}
width={width ?? (fill ? "100%" : undefined)}
height={height ?? (fill ? "100%" : undefined)}
colors={resolved.colors}
distortion={resolved.distortion}
swirl={resolved.swirl}
grainMixer={resolved.grainMixer}
grainOverlay={resolved.grainOverlay}
speed={resolved.speed}
frame={resolved.frame}
/>
)
}

View File

@@ -0,0 +1,17 @@
export {
getSeededPaperGrainGradientConfig,
getSeededPaperMeshGradientConfig,
paperGrainGradientDefaults,
paperMeshGradientDefaults,
resolvePaperGrainGradientConfig,
resolvePaperMeshGradientConfig,
} from "../common/paper"
export type {
PaperGrainGradientConfig,
PaperMeshGradientConfig,
SeededPaperOption,
} from "../common/paper"
export { PaperGrainGradient } from "./paper/grain-gradient"
export type { PaperGrainGradientProps } from "./paper/grain-gradient"
export { PaperMeshGradient } from "./paper/mesh-gradient"
export type { PaperMeshGradientProps } from "./paper/mesh-gradient"

View File

@@ -0,0 +1,125 @@
import {
defaultObjectSizing,
defaultPatternSizing,
getShaderColorFromString,
getShaderNoiseTexture,
grainGradientFragmentShader,
GrainGradientShapes,
ShaderFitOptions,
type GrainGradientParams,
} from "@paper-design/shaders"
import type { JSX } from "solid-js"
import { resolvePaperGrainGradientConfig } from "../../common/paper"
import { SolidShaderMount } from "./shader-mount"
type SharedGrainProps = Pick<
GrainGradientParams,
"fit" | "rotation" | "scale" | "originX" | "originY" | "offsetX" | "offsetY" | "worldWidth" | "worldHeight"
>
export interface PaperGrainGradientProps
extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "ref">,
Partial<SharedGrainProps> {
ref?: (element: HTMLDivElement) => void
seed?: string
fill?: boolean
colorBack?: string
colors?: string[]
softness?: number
intensity?: number
noise?: number
shape?: GrainGradientParams["shape"]
speed?: number
frame?: number
minPixelRatio?: number
maxPixelCount?: number
webGlContextAttributes?: WebGLContextAttributes
width?: string | number
height?: string | number
}
export function PaperGrainGradient({
seed,
fill = true,
colorBack,
colors,
softness,
intensity,
noise,
shape,
speed,
frame,
fit,
rotation,
scale,
originX,
originY,
offsetX,
offsetY,
worldWidth,
worldHeight,
minPixelRatio,
maxPixelCount,
webGlContextAttributes,
width,
height,
...props
}: PaperGrainGradientProps) {
const resolved = resolvePaperGrainGradientConfig({
seed,
colorBack,
colors,
softness,
intensity,
noise,
shape,
speed,
frame,
})
const sizingDefaults = getSizingDefaults(resolved.shape)
return (
<SolidShaderMount
{...props}
width={width ?? (fill ? "100%" : undefined)}
height={height ?? (fill ? "100%" : undefined)}
speed={resolved.speed}
frame={resolved.frame}
minPixelRatio={minPixelRatio}
maxPixelCount={maxPixelCount}
webGlContextAttributes={webGlContextAttributes}
fragmentShader={grainGradientFragmentShader}
uniforms={{
u_colorBack: getShaderColorFromString(resolved.colorBack),
u_colors: resolved.colors.map(getShaderColorFromString),
u_colorsCount: resolved.colors.length,
u_softness: resolved.softness,
u_intensity: resolved.intensity,
u_noise: resolved.noise,
u_shape: GrainGradientShapes[resolved.shape],
u_noiseTexture: getShaderNoiseTexture(),
u_fit: ShaderFitOptions[fit ?? sizingDefaults.fit],
u_scale: scale ?? sizingDefaults.scale,
u_rotation: rotation ?? sizingDefaults.rotation,
u_offsetX: offsetX ?? sizingDefaults.offsetX,
u_offsetY: offsetY ?? sizingDefaults.offsetY,
u_originX: originX ?? sizingDefaults.originX,
u_originY: originY ?? sizingDefaults.originY,
u_worldWidth: worldWidth ?? sizingDefaults.worldWidth,
u_worldHeight: worldHeight ?? sizingDefaults.worldHeight,
}}
/>
)
}
function getSizingDefaults(shape: NonNullable<GrainGradientParams["shape"]>) {
switch (shape) {
case "wave":
case "dots":
case "truchet":
return defaultPatternSizing
default:
return defaultObjectSizing
}
}

View File

@@ -0,0 +1,104 @@
import {
defaultObjectSizing,
getShaderColorFromString,
meshGradientFragmentShader,
ShaderFitOptions,
type MeshGradientParams,
} from "@paper-design/shaders"
import type { JSX } from "solid-js"
import { resolvePaperMeshGradientConfig } from "../../common/paper"
import { SolidShaderMount } from "./shader-mount"
type SharedMeshProps = Pick<
MeshGradientParams,
"fit" | "rotation" | "scale" | "originX" | "originY" | "offsetX" | "offsetY" | "worldWidth" | "worldHeight"
>
export interface PaperMeshGradientProps
extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "ref">,
Partial<SharedMeshProps> {
ref?: (element: HTMLDivElement) => void
seed?: string
fill?: boolean
colors?: string[]
distortion?: number
swirl?: number
grainMixer?: number
grainOverlay?: number
speed?: number
frame?: number
minPixelRatio?: number
maxPixelCount?: number
webGlContextAttributes?: WebGLContextAttributes
width?: string | number
height?: string | number
}
export function PaperMeshGradient({
seed,
fill = true,
colors,
distortion,
swirl,
grainMixer,
grainOverlay,
speed,
frame,
fit = defaultObjectSizing.fit,
rotation = defaultObjectSizing.rotation,
scale = defaultObjectSizing.scale,
originX = defaultObjectSizing.originX,
originY = defaultObjectSizing.originY,
offsetX = defaultObjectSizing.offsetX,
offsetY = defaultObjectSizing.offsetY,
worldWidth = defaultObjectSizing.worldWidth,
worldHeight = defaultObjectSizing.worldHeight,
minPixelRatio,
maxPixelCount,
webGlContextAttributes,
width,
height,
...props
}: PaperMeshGradientProps) {
const resolved = resolvePaperMeshGradientConfig({
seed,
colors,
distortion,
swirl,
grainMixer,
grainOverlay,
speed,
frame,
})
return (
<SolidShaderMount
{...props}
width={width ?? (fill ? "100%" : undefined)}
height={height ?? (fill ? "100%" : undefined)}
speed={resolved.speed}
frame={resolved.frame}
minPixelRatio={minPixelRatio}
maxPixelCount={maxPixelCount}
webGlContextAttributes={webGlContextAttributes}
fragmentShader={meshGradientFragmentShader}
uniforms={{
u_colors: resolved.colors.map(getShaderColorFromString),
u_colorsCount: resolved.colors.length,
u_distortion: resolved.distortion,
u_swirl: resolved.swirl,
u_grainMixer: resolved.grainMixer,
u_grainOverlay: resolved.grainOverlay,
u_fit: ShaderFitOptions[fit],
u_rotation: rotation,
u_scale: scale,
u_offsetX: offsetX,
u_offsetY: offsetY,
u_originX: originX,
u_originY: originY,
u_worldWidth: worldWidth,
u_worldHeight: worldHeight,
}}
/>
)
}

View File

@@ -0,0 +1,115 @@
import { ShaderMount, type ShaderMountUniforms } from "@paper-design/shaders"
import { createEffect, onCleanup, onMount, splitProps, type JSX } from "solid-js"
type SolidShaderMountProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, "ref"> & {
ref?: (element: HTMLDivElement) => void
fragmentShader: string
uniforms: ShaderMountUniforms
speed?: number
frame?: number
minPixelRatio?: number
maxPixelCount?: number
webGlContextAttributes?: WebGLContextAttributes
width?: string | number
height?: string | number
}
export function SolidShaderMount(props: SolidShaderMountProps) {
const [local, rest] = splitProps(props, [
"ref",
"fragmentShader",
"uniforms",
"speed",
"frame",
"minPixelRatio",
"maxPixelCount",
"webGlContextAttributes",
"width",
"height",
"style",
])
let element: HTMLDivElement | undefined
let shaderMount: ShaderMount | undefined
onMount(() => {
if (!element) {
return
}
shaderMount = new ShaderMount(
element,
local.fragmentShader,
local.uniforms,
local.webGlContextAttributes,
local.speed,
local.frame,
local.minPixelRatio,
local.maxPixelCount,
)
onCleanup(() => {
shaderMount?.dispose()
shaderMount = undefined
})
})
createEffect(() => {
shaderMount?.setUniforms(local.uniforms)
})
createEffect(() => {
shaderMount?.setSpeed(local.speed)
})
createEffect(() => {
if (local.frame !== undefined) {
shaderMount?.setFrame(local.frame)
}
})
createEffect(() => {
shaderMount?.setMinPixelRatio(local.minPixelRatio)
})
createEffect(() => {
shaderMount?.setMaxPixelCount(local.maxPixelCount)
})
return (
<div
{...rest}
ref={(node) => {
element = node
local.ref?.(node)
}}
style={mergeStyle(local.style, local.width, local.height)}
/>
)
}
function mergeStyle(
style: JSX.CSSProperties | string | undefined,
width: string | number | undefined,
height: string | number | undefined,
) {
if (typeof style === "string") {
return [
width !== undefined ? `width:${toCssSize(width)}` : "",
height !== undefined ? `height:${toCssSize(height)}` : "",
style,
]
.filter(Boolean)
.join(";")
}
return {
...(style ?? {}),
...(width !== undefined ? { width: toCssSize(width) } : {}),
...(height !== undefined ? { height: toCssSize(height) } : {}),
}
}
function toCssSize(value: string | number) {
return typeof value === "number" ? `${value}px` : value
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"jsx": "react-jsx"
},
"include": [
"src/common/**/*",
"src/react/**/*"
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"jsx": "preserve",
"jsxImportSource": "solid-js/h"
},
"include": [
"src/common/**/*",
"src/solid/**/*"
]
}

View File

@@ -0,0 +1,46 @@
import { defineConfig } from "tsup"
export default defineConfig([
{
entry: {
"react/index": "src/react/index.ts",
},
tsconfig: "./tsconfig.react.json",
format: ["esm"],
dts: {
tsconfig: "./tsconfig.react.json",
},
clean: true,
target: "es2022",
platform: "browser",
sourcemap: false,
splitting: false,
treeshake: true,
external: ["react", "react/jsx-runtime"],
esbuildOptions(options) {
options.jsx = "automatic"
options.jsxImportSource = "react"
},
},
{
entry: {
"solid/index": "src/solid/index.ts",
},
tsconfig: "./tsconfig.solid.json",
format: ["esm"],
dts: {
tsconfig: "./tsconfig.solid.json",
},
clean: false,
target: "es2022",
platform: "browser",
sourcemap: false,
splitting: false,
treeshake: true,
external: ["solid-js", "solid-js/jsx-runtime"],
esbuildOptions(options) {
options.jsx = "automatic"
options.jsxImportSource = "solid-js/h"
},
},
])

872
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff