From 8dcc24710ecad989413c85d025b4abdf98fd34ef Mon Sep 17 00:00:00 2001 From: aomona Date: Thu, 20 Aug 2026 10:29:20 +0900 Subject: [PATCH 1/2] Migrate playground to vanilla TypeScript --- README.md | 2 +- playground/index.html | 156 +++++++++++++- playground/src/App.tsx | 445 ---------------------------------------- playground/src/main.ts | 339 ++++++++++++++++++++++++++++++ playground/src/main.tsx | 13 -- 5 files changed, 494 insertions(+), 461 deletions(-) delete mode 100644 playground/src/App.tsx create mode 100644 playground/src/main.ts delete mode 100644 playground/src/main.tsx diff --git a/README.md b/README.md index 5718421..7bb91a2 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Raise these only when you need sharper or smoother animated rendering. ## Development - `npm run build`: compile the TypeScript source to `dist/` -- `npm run build:playground`: build the Vite React playground to `dist-playground/` +- `npm run build:playground`: build the Vite playground to `dist-playground/` - `npm run test`: run the Node test suite after building - `npm run lint`: check the codebase with oxlint - `npm run format:check`: verify formatting with oxfmt diff --git a/playground/index.html b/playground/index.html index 53e0ea7..0e31f8d 100644 --- a/playground/index.html +++ b/playground/index.html @@ -7,7 +7,159 @@ -
- +
+
+ + +
+ +
+
+
+ diff --git a/playground/src/App.tsx b/playground/src/App.tsx deleted file mode 100644 index df98f1d..0000000 --- a/playground/src/App.tsx +++ /dev/null @@ -1,445 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import type { MotionPreset } from "../../src/core.js"; -import { presets } from "../../src/core.js"; -import { - createWebGLMeshRenderer, - type WebGLMeshGradientOptions, - type WebGLMeshRenderer, -} from "../../src/webgl.js"; - -type PresetName = keyof typeof presets; - -interface PlaygroundState { - preset: PresetName; - colors: string[]; - baseColor: string; - intensity: number; - saturation: number; - swirl: number; - opacity: number; - frequency: number; - contrast: number; - seed: number; - motionPreset: MotionPreset; - motionSpeed: number; - motionIntensity: number; -} - -interface FeedbackState { - state: "ready" | "error"; - title: string; - text: string; -} - -const presetNames = Object.keys(presets) as PresetName[]; -const initialPreset: PresetName = "Aurora Citrus"; - -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); - -const format = (value: number, digits = 2) => { - const next = Number(value).toFixed(digits); - return next.replace(/\.00$/, "").replace(/(\.\d)0$/, "$1"); -}; - -const defaultMotionPreset = (): MotionPreset => - typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches - ? "none" - : "drift"; - -const createInitialState = (): PlaygroundState => ({ - preset: initialPreset, - colors: [...presets[initialPreset].colors], - baseColor: presets[initialPreset].baseColor, - intensity: 1.45, - saturation: 1.28, - swirl: 30, - opacity: 0.2, - frequency: 1.25, - contrast: 1.7, - seed: 1, - motionPreset: defaultMotionPreset(), - motionSpeed: 38, - motionIntensity: 46, -}); - -export function App() { - const [state, setState] = useState(createInitialState); - const [feedback, setFeedback] = useState({ - state: "ready", - title: "Ready", - text: "Preview is live.", - }); - const [ready, setReady] = useState(false); - - const canvasRef = useRef(null); - const rendererRef = useRef(null); - const rendererOptionsRef = useRef({}); - - const set = (key: Key, value: PlaygroundState[Key]) => - setState((previous) => ({ ...previous, [key]: value })); - - const rendererOptions = useMemo( - () => ({ - colors: state.colors, - baseColor: state.baseColor, - intensity: state.intensity, - saturation: state.saturation, - swirl: state.swirl, - seed: state.seed, - frequency: state.frequency, - contrast: state.contrast, - opacity: state.opacity, - motionPreset: state.motionPreset, - motionSpeed: state.motionSpeed, - motionIntensity: state.motionIntensity, - maxPixelRatio: 1.25, - fps: 45, - }), - [state], - ); - rendererOptionsRef.current = rendererOptions; - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - let activeRenderer: WebGLMeshRenderer | null = null; - let resizeObserver: ResizeObserver | null = null; - - const stopAndDestroy = () => { - activeRenderer?.destroy(); - activeRenderer = null; - rendererRef.current = null; - }; - - const handleResize = () => activeRenderer?.resize(); - - const createRenderer = () => { - stopAndDestroy(); - const renderer = createWebGLMeshRenderer(canvas, rendererOptionsRef.current); - if (!renderer) { - setReady(false); - setFeedback({ - state: "error", - title: "WebGL unavailable", - text: "This browser could not create a WebGL context.", - }); - return; - } - activeRenderer = renderer; - rendererRef.current = renderer; - renderer.resize(); - renderer.start(); - setReady(true); - setFeedback({ state: "ready", title: "Ready", text: "Preview is live." }); - }; - - const handleContextLost = (event: Event) => { - event.preventDefault(); - stopAndDestroy(); - setReady(false); - setFeedback({ - state: "error", - title: "WebGL context lost", - text: "The WebGL context was lost. Restoring if the browser allows it...", - }); - }; - - const handleContextRestored = (event: Event) => { - event.preventDefault(); - createRenderer(); - }; - - resizeObserver = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(handleResize); - resizeObserver?.observe(canvas); - if (!resizeObserver) window.addEventListener("resize", handleResize); - canvas.addEventListener("webglcontextlost", handleContextLost); - canvas.addEventListener("webglcontextrestored", handleContextRestored); - - createRenderer(); - - return () => { - canvas.removeEventListener("webglcontextlost", handleContextLost); - canvas.removeEventListener("webglcontextrestored", handleContextRestored); - resizeObserver?.disconnect(); - if (!resizeObserver) window.removeEventListener("resize", handleResize); - stopAndDestroy(); - setReady(false); - }; - }, []); - - useEffect(() => { - rendererRef.current?.update(rendererOptions); - }, [rendererOptions]); - - const applyPreset = (name: PresetName) => - setState((previous) => ({ - ...previous, - preset: name, - colors: [...presets[name].colors], - baseColor: presets[name].baseColor, - })); - - const syncSeed = (value: string) => set("seed", clamp(Math.round(Number(value) || 0), 0, 9999)); - - const resetPreset = () => { - const next = createInitialState(); - next.colors = [...presets[initialPreset].colors]; - next.baseColor = presets[initialPreset].baseColor; - setState(next); - }; - - const motionSpeedLabel = - state.motionPreset === "none" || state.motionSpeed === 0 ? "static" : `${state.motionSpeed}`; - - return ( -
- - -
-
-
- ); -} diff --git a/playground/src/main.ts b/playground/src/main.ts new file mode 100644 index 0000000..cf4b542 --- /dev/null +++ b/playground/src/main.ts @@ -0,0 +1,339 @@ +import type { MotionPreset } from "../../src/core.js"; +import { presets } from "../../src/core.js"; +import { + createWebGLMeshRenderer, + type WebGLMeshGradientOptions, + type WebGLMeshRenderer, +} from "../../src/webgl.js"; +import "./styles.css"; + +type PresetName = keyof typeof presets; + +interface PlaygroundState { + preset: PresetName; + colors: string[]; + baseColor: string; + intensity: number; + saturation: number; + swirl: number; + opacity: number; + frequency: number; + contrast: number; + seed: number; + motionPreset: MotionPreset; + motionSpeed: number; + motionIntensity: number; +} + +interface FeedbackState { + state: "ready" | "error"; + title: string; + text: string; +} + +const initialPreset: PresetName = "Aurora Citrus"; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const format = (value: number, digits = 2) => { + const next = Number(value).toFixed(digits); + return next.replace(/\.00$/, "").replace(/(\.\d)0$/, "$1"); +}; + +const defaultMotionPreset = (): MotionPreset => + window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "none" : "drift"; + +const createInitialState = (): PlaygroundState => ({ + preset: initialPreset, + colors: [...presets[initialPreset].colors], + baseColor: presets[initialPreset].baseColor, + intensity: 1.45, + saturation: 1.28, + swirl: 30, + opacity: 0.2, + frequency: 1.25, + contrast: 1.7, + seed: 1, + motionPreset: defaultMotionPreset(), + motionSpeed: 38, + motionIntensity: 46, +}); + +const select = (selector: string) => { + const element = document.querySelector(selector); + if (!element) throw new Error(`Missing ${selector} element`); + return element; +}; + +const valueFor = (control: HTMLElement) => { + const value = control.closest(".field")?.querySelector(".label-row .value"); + if (!value) throw new Error(`Missing value for #${control.id}`); + return value; +}; + +const preset = select("#preset"); +const colorInputs = Array.from( + document.querySelectorAll('.colors input[type="color"]'), +); +const intensity = select("#intensity"); +const saturation = select("#saturation"); +const swirl = select("#swirl"); +const motionPreset = select("#motionPreset"); +const motionSpeed = select("#motionSpeed"); +const motionIntensity = select("#motionIntensity"); +const opacity = select("#opacity"); +const frequency = select("#frequency"); +const contrast = select("#contrast"); +const seedRange = select("#seedRange"); +const seedNumber = select("#seedNumber"); +const reset = select(".button-row .secondary"); +const feedback = select(".feedback"); +const feedbackTitle = select(".feedback strong"); +const feedbackText = select(".feedback p"); +const preview = select(".preview"); +const canvas = select(".preview-canvas"); + +const intensityValue = valueFor(intensity); +const saturationValue = valueFor(saturation); +const swirlValue = valueFor(swirl); +const motionSpeedValue = valueFor(motionSpeed); +const motionIntensityValue = valueFor(motionIntensity); +const opacityValue = valueFor(opacity); +const frequencyValue = valueFor(frequency); +const contrastValue = valueFor(contrast); +const seedValue = valueFor(seedRange); + +let state = createInitialState(); +let renderer: WebGLMeshRenderer | null = null; + +const rendererOptions = (): WebGLMeshGradientOptions => ({ + colors: state.colors, + baseColor: state.baseColor, + intensity: state.intensity, + saturation: state.saturation, + swirl: state.swirl, + seed: state.seed, + frequency: state.frequency, + contrast: state.contrast, + opacity: state.opacity, + motionPreset: state.motionPreset, + motionSpeed: state.motionSpeed, + motionIntensity: state.motionIntensity, + maxPixelRatio: 1.25, + fps: 45, +}); + +const setFeedback = (next: FeedbackState) => { + feedback.dataset.state = next.state; + feedbackTitle.textContent = next.title; + feedbackText.textContent = next.text; +}; + +const setReady = (ready: boolean) => { + canvas.dataset.ready = String(ready); +}; + +const motionSpeedLabel = () => + state.motionPreset === "none" || state.motionSpeed === 0 ? "static" : `${state.motionSpeed}`; + +const syncControls = () => { + preset.value = state.preset; + colorInputs.forEach((input, index) => { + input.value = state.colors[index] ?? state.colors[0] ?? "#ffffff"; + }); + intensity.value = String(state.intensity); + intensityValue.textContent = format(state.intensity, 2); + saturation.value = String(state.saturation); + saturationValue.textContent = format(state.saturation, 2); + swirl.value = String(state.swirl); + swirlValue.textContent = String(state.swirl); + motionPreset.value = state.motionPreset; + motionSpeed.value = String(state.motionSpeed); + motionSpeedValue.textContent = motionSpeedLabel(); + motionIntensity.value = String(state.motionIntensity); + motionIntensityValue.textContent = String(state.motionIntensity); + opacity.value = String(state.opacity); + opacityValue.textContent = format(state.opacity, 2); + frequency.value = String(state.frequency); + frequencyValue.textContent = format(state.frequency, 2); + contrast.value = String(state.contrast); + contrastValue.textContent = format(state.contrast, 2); + seedRange.value = String(state.seed); + seedNumber.value = String(state.seed); + seedValue.textContent = String(state.seed); + preview.style.background = state.baseColor; +}; + +const updateRenderer = () => renderer?.update(rendererOptions()); + +const bindNumberControl = ( + input: HTMLInputElement, + setValue: (value: number) => void, + value: HTMLElement, + renderValue: (value: number) => string = String, +) => { + input.addEventListener("input", () => { + const next = Number(input.value); + setValue(next); + value.textContent = renderValue(next); + updateRenderer(); + }); +}; + +preset.addEventListener("change", () => { + const name = preset.value as PresetName; + state.preset = name; + state.colors = [...presets[name].colors]; + state.baseColor = presets[name].baseColor; + colorInputs.forEach((input, index) => { + input.value = state.colors[index] ?? state.colors[0] ?? "#ffffff"; + }); + preview.style.background = state.baseColor; + updateRenderer(); +}); + +colorInputs.forEach((input, index) => { + input.addEventListener("input", () => { + state.colors = state.colors.map((color, colorIndex) => + colorIndex === index ? input.value : color, + ); + updateRenderer(); + }); +}); + +bindNumberControl( + intensity, + (value) => { + state.intensity = value; + }, + intensityValue, + (value) => format(value, 2), +); +bindNumberControl( + saturation, + (value) => { + state.saturation = value; + }, + saturationValue, + (value) => format(value, 2), +); +bindNumberControl( + swirl, + (value) => { + state.swirl = value; + }, + swirlValue, +); + +motionPreset.addEventListener("change", () => { + state.motionPreset = motionPreset.value as MotionPreset; + motionSpeedValue.textContent = motionSpeedLabel(); + updateRenderer(); +}); + +bindNumberControl( + motionSpeed, + (value) => { + state.motionSpeed = value; + }, + motionSpeedValue, + () => motionSpeedLabel(), +); +bindNumberControl( + motionIntensity, + (value) => { + state.motionIntensity = value; + }, + motionIntensityValue, +); +bindNumberControl( + opacity, + (value) => { + state.opacity = value; + }, + opacityValue, + (value) => format(value, 2), +); +bindNumberControl( + frequency, + (value) => { + state.frequency = value; + }, + frequencyValue, + (value) => format(value, 2), +); +bindNumberControl( + contrast, + (value) => { + state.contrast = value; + }, + contrastValue, + (value) => format(value, 2), +); + +const syncSeed = (value: string) => { + state.seed = clamp(Math.round(Number(value) || 0), 0, 9999); + seedRange.value = String(state.seed); + seedNumber.value = String(state.seed); + seedValue.textContent = String(state.seed); + updateRenderer(); +}; + +seedRange.addEventListener("input", () => syncSeed(seedRange.value)); +seedNumber.addEventListener("input", () => syncSeed(seedNumber.value)); + +reset.addEventListener("click", () => { + state = createInitialState(); + syncControls(); + updateRenderer(); +}); + +const stopAndDestroy = () => { + renderer?.destroy(); + renderer = null; +}; + +const createRenderer = () => { + stopAndDestroy(); + const nextRenderer = createWebGLMeshRenderer(canvas, rendererOptions()); + if (!nextRenderer) { + setReady(false); + setFeedback({ + state: "error", + title: "WebGL unavailable", + text: "This browser could not create a WebGL context.", + }); + return; + } + + renderer = nextRenderer; + renderer.start(); + setReady(true); + setFeedback({ state: "ready", title: "Ready", text: "Preview is live." }); +}; + +const handleResize = () => renderer?.resize(); +const resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(handleResize); + +resizeObserver?.observe(canvas); +if (!resizeObserver) window.addEventListener("resize", handleResize); + +canvas.addEventListener("webglcontextlost", (event) => { + event.preventDefault(); + stopAndDestroy(); + setReady(false); + setFeedback({ + state: "error", + title: "WebGL context lost", + text: "The WebGL context was lost. Restoring if the browser allows it...", + }); +}); + +canvas.addEventListener("webglcontextrestored", (event) => { + event.preventDefault(); + createRenderer(); +}); + +syncControls(); +createRenderer(); diff --git a/playground/src/main.tsx b/playground/src/main.tsx deleted file mode 100644 index 185a4d5..0000000 --- a/playground/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { App } from "./App.js"; -import "./styles.css"; - -const container = document.getElementById("root"); -if (!container) throw new Error("Missing #root element"); - -createRoot(container).render( - - - , -); From fe90ba26d9c2e282de464fcf464910021ae0f88b Mon Sep 17 00:00:00 2001 From: aomona Date: Thu, 20 Aug 2026 10:30:45 +0900 Subject: [PATCH 2/2] Remove ReactDOM from playground tooling --- package-lock.json | 59 ----------------------------------------------- package.json | 3 --- vite.config.ts | 2 -- 3 files changed, 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48d3eac..157c08c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,9 @@ "license": "MIT", "devDependencies": { "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", "oxfmt": "^0.51.0", "oxlint": "^1.66.0", "react": "^19.2.7", - "react-dom": "^19.2.7", "typescript": "^5.5.4", "vite": "^8.0.14" }, @@ -1088,42 +1085,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1618,19 +1579,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -1665,13 +1613,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 96754ce..a944a2e 100644 --- a/package.json +++ b/package.json @@ -64,12 +64,9 @@ }, "devDependencies": { "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", "oxfmt": "^0.51.0", "oxlint": "^1.66.0", "react": "^19.2.7", - "react-dom": "^19.2.7", "typescript": "^5.5.4", "vite": "^8.0.14" }, diff --git a/vite.config.ts b/vite.config.ts index dbe1f35..861d26a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,8 @@ -import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ root: "playground", base: "./", - plugins: [react()], build: { outDir: "../dist-playground", emptyOutDir: true,