Refactoring start
This commit is contained in:
parent
6588930911
commit
b1acdff594
19 changed files with 528 additions and 97 deletions
|
|
@ -28,11 +28,14 @@ jobs:
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint -- --check || true
|
run: npm run lint
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -1,15 +1,7 @@
|
||||||
# Just a bunch of blobs
|
# Fleeting Garden
|
||||||
|
|
||||||
[](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml)
|
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
|
||||||
|
draw persistent coloured paths, spawn agents from those strokes, erase locally,
|
||||||
|
and export the scene as a 4K wallpaper.
|
||||||
|
|
||||||
## todo
|
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
||||||
|
|
||||||
- add info page description
|
|
||||||
- add share link
|
|
||||||
- settings page
|
|
||||||
add reset link
|
|
||||||
- shareable settings
|
|
||||||
- graceful error messages when no support
|
|
||||||
- fix up generation id automatically
|
|
||||||
|
|
||||||
Check out the [agent's logic](./src/pipelines/agents/agent.wgsl).
|
|
||||||
|
|
|
||||||
10
assets/icons/download.svg
Normal file
10
assets/icons/download.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="black"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 239 B |
3
assets/icons/sound.svg
Normal file
3
assets/icons/sound.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 369 B |
150
index.html
150
index.html
|
|
@ -9,13 +9,13 @@
|
||||||
<meta name="theme-color" content="#b7455e" />
|
<meta name="theme-color" content="#b7455e" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
|
content="Fleeting Garden is a joyful WebGPU drawing garden where your coloured paths bloom into moving organic trails."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<meta property="og:title" content="Just a bunch of blobs" />
|
<meta property="og:title" content="Fleeting Garden" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
|
content="Pick a vibe, draw coloured paths, and watch them grow into a living WebGPU garden."
|
||||||
/>
|
/>
|
||||||
<meta property="og:url" content="https://schmelczer.dev" />
|
<meta property="og:url" content="https://schmelczer.dev" />
|
||||||
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
|
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
|
||||||
|
|
@ -27,38 +27,42 @@
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
|
||||||
<title>Just a bunch of blobs</title>
|
<title>Fleeting Garden</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="canvas-container">
|
<main class="canvas-container">
|
||||||
<canvas></canvas>
|
<canvas
|
||||||
|
role="img"
|
||||||
|
aria-label="Interactive generative garden canvas"
|
||||||
|
aria-describedby="canvas-description"
|
||||||
|
>
|
||||||
|
Your browser cannot display the interactive WebGPU garden canvas. Use a browser
|
||||||
|
with WebGPU support to draw coloured paths and watch the garden grow.
|
||||||
|
</canvas>
|
||||||
|
<p id="canvas-description" class="visually-hidden">
|
||||||
|
Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene
|
||||||
|
to paint coloured paths, then use the toolbar to change colours, erase, adjust
|
||||||
|
settings, export, restart, or open more information.
|
||||||
|
</p>
|
||||||
|
<div class="eraser-preview" aria-hidden="true"></div>
|
||||||
|
<div class="garden-prompt" aria-live="polite"></div>
|
||||||
|
|
||||||
<section class="errors-container">
|
<section class="errors-container">
|
||||||
<noscript>JavaScript is required for this website.</noscript>
|
<noscript>JavaScript is required for this website.</noscript>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside>
|
<aside class="control-dock">
|
||||||
<nav class="buttons">
|
<section id="info-panel" class="pages hidden info-page" aria-hidden="true" inert>
|
||||||
<button class="info" aria-label="About"></button>
|
|
||||||
<button class="maximize-full-screen" aria-label="Enter fullscreen"></button>
|
|
||||||
<button class="minimize-full-screen" aria-label="Exit fullscreen"></button>
|
|
||||||
<button class="settings" aria-label="Settings"></button>
|
|
||||||
<button class="restart" aria-label="Restart simulation"></button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="pages hidden info-page">
|
|
||||||
<section>
|
<section>
|
||||||
<h1>Just a bunch of blobs</h1>
|
<h1>Fleeting Garden</h1>
|
||||||
<p>
|
<p>
|
||||||
A million autonomous agents wander a 2D field. Each one lays down a faint
|
Pick a vibe palette, draw with one of the three colours, and agents grow
|
||||||
trail and follows trails it senses ahead. Two generations are competing for
|
organic paths from your strokes.
|
||||||
territory: the older one fades, the newer one spreads.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Drag your finger or mouse anywhere on the canvas to paint a wall. Walls slow
|
Your drawn paths persist until you erase them. Switching vibes recolours the
|
||||||
the new generation down and let the old one breathe a little longer. Open
|
whole garden without clearing the scene.
|
||||||
<em>Settings</em> to retune sensors, decay rates and aggression.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Runs entirely on your GPU via WebGPU compute shaders — no servers, no
|
Runs entirely on your GPU via WebGPU compute shaders — no servers, no
|
||||||
|
|
@ -68,14 +72,110 @@
|
||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</section>
|
||||||
|
|
||||||
<main class="pages hidden settings-page">
|
<section
|
||||||
|
id="settings-panel"
|
||||||
|
class="pages hidden settings-page"
|
||||||
|
aria-hidden="true"
|
||||||
|
inert
|
||||||
|
>
|
||||||
<section>
|
<section>
|
||||||
<div class="settings-content"></div>
|
<div class="settings-content"></div>
|
||||||
<button id="apply-defaults" class="large-button">Apply defaults</button>
|
<button id="apply-defaults" class="large-button">Apply defaults</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</section>
|
||||||
|
|
||||||
|
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
|
||||||
|
<button
|
||||||
|
class="previous-vibe vibe-button"
|
||||||
|
aria-label="Previous vibe"
|
||||||
|
title="Previous vibe"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="toolbar-shell">
|
||||||
|
<section class="garden-controls" aria-label="Garden controls">
|
||||||
|
<div class="swatches" aria-label="Drawing colours">
|
||||||
|
<button
|
||||||
|
class="color-swatch"
|
||||||
|
aria-label="Draw colour 1"
|
||||||
|
title="Draw colour 1"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="color-swatch"
|
||||||
|
aria-label="Draw colour 2"
|
||||||
|
title="Draw colour 2"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="color-swatch"
|
||||||
|
aria-label="Draw colour 3"
|
||||||
|
title="Draw colour 3"
|
||||||
|
></button>
|
||||||
|
<label class="eraser-size-control" title="Erase and resize">
|
||||||
|
<input
|
||||||
|
class="eraser-size-slider"
|
||||||
|
type="range"
|
||||||
|
min="24"
|
||||||
|
max="240"
|
||||||
|
step="1"
|
||||||
|
aria-label="Eraser size"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="buttons" aria-label="App controls">
|
||||||
|
<button
|
||||||
|
class="info"
|
||||||
|
aria-label="About"
|
||||||
|
aria-controls="info-panel"
|
||||||
|
aria-expanded="false"
|
||||||
|
title="About"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="maximize-full-screen"
|
||||||
|
aria-label="Enter fullscreen"
|
||||||
|
title="Enter fullscreen"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="minimize-full-screen"
|
||||||
|
aria-label="Exit fullscreen"
|
||||||
|
hidden
|
||||||
|
title="Exit fullscreen"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="settings"
|
||||||
|
aria-label="Settings"
|
||||||
|
aria-controls="settings-panel"
|
||||||
|
aria-expanded="false"
|
||||||
|
title="Settings"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="sound"
|
||||||
|
aria-label="Mute audio"
|
||||||
|
aria-pressed="false"
|
||||||
|
title="Mute audio"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="export-4k"
|
||||||
|
aria-label="Download 4K image"
|
||||||
|
title="Download 4K image"
|
||||||
|
></button>
|
||||||
|
<span class="export-status" aria-live="polite"></span>
|
||||||
|
<button
|
||||||
|
class="restart"
|
||||||
|
aria-label="Restart simulation"
|
||||||
|
title="Restart simulation"
|
||||||
|
></button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="next-vibe vibe-button" aria-label="Next vibe" title="Next vibe">
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<script type="module" src="/src/index.ts"></script>
|
<script type="module" src="/src/index.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "webgpu-seed",
|
"name": "fleeting-garden",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "webgpu-seed",
|
"name": "fleeting-garden",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
14
package.json
14
package.json
|
|
@ -1,14 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "webgpu-seed",
|
"name": "fleeting-garden",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "A WebGPU-powered slime-mold-meets-territory-control simulation.",
|
"description": "A WebGPU drawing garden where coloured paths grow into organic agent trails.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.{ts,scss,json,html}\"",
|
"lint": "npm run lint:check",
|
||||||
|
"lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\"",
|
||||||
|
"lint:fix": "eslint --fix \"src/**/*.ts\"",
|
||||||
|
"format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||||
|
"format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|
@ -33,10 +37,8 @@
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"supports webgpu and last 2 years"
|
"supports webgpu and last 2 years"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
|
||||||
"gl-matrix": "^3.4.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
|
|
||||||
6
public/audio/piano/README.md
Normal file
6
public/audio/piano/README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Piano samples are Salamander Grand Piano V3 OGG samples by Alexander Holm,
|
||||||
|
distributed under CC BY 3.0.
|
||||||
|
|
||||||
|
Source package: @audio-samples/piano-velocity12
|
||||||
|
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
||||||
|
License: https://creativecommons.org/licenses/by/3.0/
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "Just a bunch of blobs",
|
"name": "Fleeting Garden",
|
||||||
"short_name": "Blobs",
|
"short_name": "Garden",
|
||||||
"description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.",
|
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "fullscreen",
|
"display": "fullscreen",
|
||||||
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"background_color": "#b7455e",
|
"background_color": "#10151f",
|
||||||
"theme_color": "#b7455e",
|
"theme_color": "#10151f",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/favicon.svg",
|
"src": "/favicon.svg",
|
||||||
|
|
|
||||||
46
src/audio/piano-samples.ts
Normal file
46
src/audio/piano-samples.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
export interface PianoSampleDefinition {
|
||||||
|
midi: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
||||||
|
|
||||||
|
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||||
|
['A0v12.ogg', 21],
|
||||||
|
['C1v12.ogg', 24],
|
||||||
|
['Dsharp1v12.ogg', 27],
|
||||||
|
['Fsharp1v12.ogg', 30],
|
||||||
|
['A1v12.ogg', 33],
|
||||||
|
['C2v12.ogg', 36],
|
||||||
|
['Dsharp2v12.ogg', 39],
|
||||||
|
['Fsharp2v12.ogg', 42],
|
||||||
|
['A2v12.ogg', 45],
|
||||||
|
['C3v12.ogg', 48],
|
||||||
|
['Dsharp3v12.ogg', 51],
|
||||||
|
['Fsharp3v12.ogg', 54],
|
||||||
|
['A3v12.ogg', 57],
|
||||||
|
['C4v12.ogg', 60],
|
||||||
|
['Dsharp4v12.ogg', 63],
|
||||||
|
['Fsharp4v12.ogg', 66],
|
||||||
|
['A4v12.ogg', 69],
|
||||||
|
['C5v12.ogg', 72],
|
||||||
|
['Dsharp5v12.ogg', 75],
|
||||||
|
['Fsharp5v12.ogg', 78],
|
||||||
|
['A5v12.ogg', 81],
|
||||||
|
['C6v12.ogg', 84],
|
||||||
|
['Dsharp6v12.ogg', 87],
|
||||||
|
['Fsharp6v12.ogg', 90],
|
||||||
|
['A6v12.ogg', 93],
|
||||||
|
['C7v12.ogg', 96],
|
||||||
|
['Dsharp7v12.ogg', 99],
|
||||||
|
['Fsharp7v12.ogg', 102],
|
||||||
|
['A7v12.ogg', 105],
|
||||||
|
['C8v12.ogg', 108],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||||
|
.map(([fileName, midi]) => ({
|
||||||
|
midi,
|
||||||
|
url: `${sampleBaseUrl}${fileName}`,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.midi - b.midi);
|
||||||
65
src/index.dom-contract.test.ts
Normal file
65
src/index.dom-contract.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const indexSource = readFileSync(join(projectRoot, 'src/index.ts'), 'utf8');
|
||||||
|
const html = readFileSync(join(projectRoot, 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const hasClass = (className: string, tagName?: string) => {
|
||||||
|
const tagPattern = tagName ? `<${tagName}\\b[^>]*` : '<[a-z][^>]*';
|
||||||
|
return new RegExp(
|
||||||
|
`${tagPattern}class="[^"]*\\b${escapeRegex(className)}\\b[^"]*"`,
|
||||||
|
'i'
|
||||||
|
).test(html);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasId = (id: string) => new RegExp(`\\bid="${escapeRegex(id)}"`, 'i').test(html);
|
||||||
|
|
||||||
|
const hasTag = (tagName: string) =>
|
||||||
|
new RegExp(`<${escapeRegex(tagName)}(?:\\s|>|/)`, 'i').test(html);
|
||||||
|
|
||||||
|
const selectorExists = (selector: string) => {
|
||||||
|
const idSelector = /^#(?<id>[\w-]+)$/.exec(selector);
|
||||||
|
if (idSelector?.groups?.id) {
|
||||||
|
return hasId(idSelector.groups.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classSelector = /^\.([\w-]+)$/.exec(selector);
|
||||||
|
if (classSelector?.[1]) {
|
||||||
|
return hasClass(classSelector[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagClassSelector = /^(?<tagName>[a-z]+)\.(?<className>[\w-]+)$/.exec(selector);
|
||||||
|
if (tagClassSelector?.groups) {
|
||||||
|
return hasClass(tagClassSelector.groups.className, tagClassSelector.groups.tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-z]+$/.test(selector)) {
|
||||||
|
return hasTag(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported selector contract syntax: ${selector}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('index DOM selector contract', () => {
|
||||||
|
it('keeps every boot-time querySelector target present in index.html', () => {
|
||||||
|
const selectors = Array.from(
|
||||||
|
indexSource.matchAll(/document\.querySelector(?:All)?\(\s*'([^']+)'\s*\)/g),
|
||||||
|
(match) => match[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selectors.length).toBeGreaterThan(0);
|
||||||
|
expect(selectors.filter((selector) => !selectorExists(selector))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the three color swatches expected by the palette UI', () => {
|
||||||
|
const colorSwatchCount = Array.from(
|
||||||
|
html.matchAll(/class="[^"]*\bcolor-swatch\b[^"]*"/g)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
expect(colorSwatchCount).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCachedFloat32BufferWrite,
|
||||||
|
writeFloat32BufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { generateNoise } from '../../utils/graphics/noise';
|
import { generateNoise } from '../../utils/graphics/noise';
|
||||||
|
|
||||||
export class CommonState {
|
export class CommonState {
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly UNIFORM_COUNT = 4;
|
||||||
|
private static readonly NOISE_TEXTURE_SIZE = 1024;
|
||||||
|
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
|
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
|
||||||
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
|
CommonState.UNIFORM_COUNT
|
||||||
|
);
|
||||||
private readonly noise: GPUTextureView;
|
private readonly noise: GPUTextureView;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
|
|
||||||
|
|
@ -31,8 +40,8 @@ export class CommonState {
|
||||||
|
|
||||||
this.noise = generateNoise({
|
this.noise = generateNoise({
|
||||||
device,
|
device,
|
||||||
width: 2048,
|
width: CommonState.NOISE_TEXTURE_SIZE,
|
||||||
height: 2048,
|
height: CommonState.NOISE_TEXTURE_SIZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
|
@ -95,10 +104,15 @@ export class CommonState {
|
||||||
deltaTime: number;
|
deltaTime: number;
|
||||||
time: number;
|
time: number;
|
||||||
}) {
|
}) {
|
||||||
this.device.queue.writeBuffer(
|
this.uniformValues[0] = canvasSize[0];
|
||||||
|
this.uniformValues[1] = canvasSize[1];
|
||||||
|
this.uniformValues[2] = deltaTime;
|
||||||
|
this.uniformValues[3] = time;
|
||||||
|
writeFloat32BufferIfChanged(
|
||||||
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
0,
|
this.uniformValues,
|
||||||
new Float32Array([...canvasSize, deltaTime, time])
|
this.uniformCache
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import {
|
||||||
|
createCachedFloat32BufferWrite,
|
||||||
|
writeFloat32BufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
|
|
@ -5,11 +9,15 @@ import shader from './diffuse.wgsl?raw';
|
||||||
import { DiffusionSettings } from './diffusion-settings';
|
import { DiffusionSettings } from './diffusion-settings';
|
||||||
|
|
||||||
export class DiffusionPipeline {
|
export class DiffusionPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly UNIFORM_COUNT = 5;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly pipeline: GPURenderPipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
|
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
||||||
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
|
DiffusionPipeline.UNIFORM_COUNT
|
||||||
|
);
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
private readonly vertexBuffer: GPUBuffer;
|
||||||
|
|
||||||
private bindGroup?: GPUBindGroup;
|
private bindGroup?: GPUBindGroup;
|
||||||
|
|
@ -56,16 +64,18 @@ export class DiffusionPipeline {
|
||||||
decayRateTrails,
|
decayRateTrails,
|
||||||
diffusionRateBrush,
|
diffusionRateBrush,
|
||||||
decayRateBrush,
|
decayRateBrush,
|
||||||
|
anisotropy,
|
||||||
}: DiffusionSettings) {
|
}: DiffusionSettings) {
|
||||||
this.device.queue.writeBuffer(
|
this.uniformValues[0] = 1 / diffusionRateTrails;
|
||||||
|
this.uniformValues[1] = decayRateTrails / 1000;
|
||||||
|
this.uniformValues[2] = 1 / diffusionRateBrush;
|
||||||
|
this.uniformValues[3] = decayRateBrush / 1000;
|
||||||
|
this.uniformValues[4] = anisotropy;
|
||||||
|
writeFloat32BufferIfChanged(
|
||||||
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
0,
|
this.uniformValues,
|
||||||
new Float32Array([
|
this.uniformCache
|
||||||
1 / diffusionRateTrails,
|
|
||||||
decayRateTrails / 1000,
|
|
||||||
1 / diffusionRateBrush,
|
|
||||||
decayRateBrush / 1000,
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
@media (prefers-reduced-motion) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +36,21 @@ html {
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
margin: -1px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0 0 0 0) !important;
|
||||||
|
clip-path: inset(50%) !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.large-button {
|
.large-button {
|
||||||
|
min-height: 44px;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
|
||||||
|
|
||||||
export const initializeContext = ({
|
export const initializeContext = ({
|
||||||
device,
|
device,
|
||||||
canvas,
|
canvas,
|
||||||
|
|
@ -5,13 +7,49 @@ export const initializeContext = ({
|
||||||
device: GPUDevice;
|
device: GPUDevice;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
}): GPUCanvasContext => {
|
}): GPUCanvasContext => {
|
||||||
const context = canvas.getContext('webgpu') as any as GPUCanvasContext;
|
const context = canvas.getContext('webgpu' as any) as GPUCanvasContext | null;
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_CONTEXT_UNAVAILABLE,
|
||||||
|
'Could not create a WebGPU canvas context.',
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
canvasHeight: canvas.height,
|
||||||
|
canvasWidth: canvas.width,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpu = navigator.gpu;
|
||||||
|
if (!gpu) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_UNSUPPORTED,
|
||||||
|
'WebGPU is no longer available while configuring the canvas context.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
context.configure({
|
context.configure({
|
||||||
device: device,
|
device: device,
|
||||||
format: navigator.gpu.getPreferredCanvasFormat(),
|
format: gpu.getPreferredCanvasFormat(),
|
||||||
alphaMode: 'premultiplied',
|
alphaMode: 'premultiplied',
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_CONTEXT_CONFIGURATION_FAILED,
|
||||||
|
'Could not configure the WebGPU canvas context.',
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
details: {
|
||||||
|
causeMessage: getErrorMessage(error),
|
||||||
|
canvasHeight: canvas.height,
|
||||||
|
canvasWidth: canvas.width,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,150 @@
|
||||||
import { ErrorHandler, Severity } from '../error-handler';
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
ErrorHandler,
|
||||||
|
getErrorMessage,
|
||||||
|
RuntimeError,
|
||||||
|
Severity,
|
||||||
|
} from '../error-handler';
|
||||||
|
|
||||||
|
const WEBGPU_BROWSER_SUPPORT_MESSAGE =
|
||||||
|
'Fleeting Garden needs WebGPU. Try the latest Chrome, Edge, or another browser with WebGPU enabled.';
|
||||||
|
|
||||||
|
const REQUESTED_LIMIT_NAMES = [
|
||||||
|
'maxBufferSize',
|
||||||
|
'maxStorageBufferBindingSize',
|
||||||
|
'maxComputeWorkgroupsPerDimension',
|
||||||
|
] as const satisfies ReadonlyArray<keyof GPUSupportedLimits>;
|
||||||
|
|
||||||
|
const getRequiredLimits = (
|
||||||
|
limits: GPUSupportedLimits
|
||||||
|
): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> =>
|
||||||
|
Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record<
|
||||||
|
(typeof REQUESTED_LIMIT_NAMES)[number],
|
||||||
|
number
|
||||||
|
>;
|
||||||
|
|
||||||
|
const getAdapterInfo = (adapter: GPUAdapter): Record<string, unknown> => {
|
||||||
|
try {
|
||||||
|
const info = adapter.info;
|
||||||
|
return {
|
||||||
|
architecture: info.architecture,
|
||||||
|
description: info.description,
|
||||||
|
device: info.device,
|
||||||
|
isFallbackAdapter: info.isFallbackAdapter,
|
||||||
|
subgroupMaxSize: info.subgroupMaxSize,
|
||||||
|
subgroupMinSize: info.subgroupMinSize,
|
||||||
|
vendor: info.vendor,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
unavailableReason: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestAdapter = async (
|
||||||
|
gpu: GPU,
|
||||||
|
options?: GPURequestAdapterOptions
|
||||||
|
): Promise<GPUAdapter | null> => {
|
||||||
|
try {
|
||||||
|
return await gpu.requestAdapter(options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
|
||||||
|
'Could not request a WebGPU adapter.',
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
details: {
|
||||||
|
causeMessage: getErrorMessage(error),
|
||||||
|
powerPreference: options?.powerPreference ?? 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const initializeGpu = async (): Promise<GPUDevice> => {
|
export const initializeGpu = async (): Promise<GPUDevice> => {
|
||||||
|
if (window.isSecureContext === false) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_INSECURE_CONTEXT,
|
||||||
|
'WebGPU requires a secure context. Open Fleeting Garden over HTTPS or from localhost.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const gpu = navigator.gpu;
|
const gpu = navigator.gpu;
|
||||||
if (!gpu) {
|
if (!gpu) {
|
||||||
throw new Error('WebGPU is not supported in your browser');
|
throw new RuntimeError(ErrorCode.WEBGPU_UNSUPPORTED, WEBGPU_BROWSER_SUPPORT_MESSAGE, {
|
||||||
}
|
details: {
|
||||||
|
hasNavigatorGpu: false,
|
||||||
const adapter = await gpu.requestAdapter({
|
isSecureContext: window.isSecureContext,
|
||||||
powerPreference: 'high-performance',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!adapter) {
|
|
||||||
throw new Error('Could not request adatper');
|
|
||||||
}
|
|
||||||
|
|
||||||
ErrorHandler.addMetadata('features', adapter.features);
|
|
||||||
ErrorHandler.addMetadata('limits', adapter.limits);
|
|
||||||
|
|
||||||
const gpuDevice = await adapter.requestDevice({
|
|
||||||
requiredLimits: {
|
|
||||||
maxBufferSize: adapter.limits.maxBufferSize,
|
|
||||||
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
|
|
||||||
maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter =
|
||||||
|
(await requestAdapter(gpu, {
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
})) ?? (await requestAdapter(gpu));
|
||||||
|
|
||||||
|
if (!adapter) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
|
||||||
|
'WebGPU is available, but this browser could not provide a compatible GPU adapter.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredLimits = getRequiredLimits(adapter.limits);
|
||||||
|
ErrorHandler.addMetadata('webgpuAdapter', {
|
||||||
|
features: Array.from(adapter.features).sort(),
|
||||||
|
info: getAdapterInfo(adapter),
|
||||||
|
requiredLimits,
|
||||||
|
});
|
||||||
|
|
||||||
|
let gpuDevice: GPUDevice;
|
||||||
|
try {
|
||||||
|
gpuDevice = await adapter.requestDevice({
|
||||||
|
requiredLimits,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
|
||||||
|
'Could not create a WebGPU device for this adapter.',
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
details: {
|
||||||
|
causeMessage: getErrorMessage(error),
|
||||||
|
requiredLimits,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gpuDevice) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
|
||||||
|
'The browser returned an empty WebGPU device.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) =>
|
gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) =>
|
||||||
ErrorHandler.addError(Severity.ERROR, event.error.message)
|
ErrorHandler.addException(event.error, {
|
||||||
|
code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,
|
||||||
|
severity: Severity.ERROR,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
gpuDevice.lost.then((info) => {
|
||||||
|
if (info.reason === 'destroyed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorHandler.addError(Severity.ERROR, info.message || 'The WebGPU device was lost.', {
|
||||||
|
code: ErrorCode.WEBGPU_DEVICE_LOST,
|
||||||
|
details: {
|
||||||
|
reason: info.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return gpuDevice;
|
return gpuDevice;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { setUpFullScreenQuad } from './full-screen-quad';
|
import { setUpFullScreenQuad } from './full-screen-quad';
|
||||||
import { smartCompile } from './smart-compile';
|
import { smartCompile } from './smart-compile';
|
||||||
|
|
||||||
const textureCache = new Map<string, GPUTexture>();
|
const textureCache = new WeakMap<GPUDevice, Map<string, GPUTexture>>();
|
||||||
|
const NOISE_TEXTURE_FORMAT: GPUTextureFormat = 'rgba8unorm';
|
||||||
|
|
||||||
export const generateNoise = ({
|
export const generateNoise = ({
|
||||||
device,
|
device,
|
||||||
|
|
@ -13,7 +14,13 @@ export const generateNoise = ({
|
||||||
height: number;
|
height: number;
|
||||||
}): GPUTextureView => {
|
}): GPUTextureView => {
|
||||||
const cacheKey = `${width}x${height}`;
|
const cacheKey = `${width}x${height}`;
|
||||||
const cached = textureCache.get(cacheKey);
|
let deviceCache = textureCache.get(device);
|
||||||
|
if (!deviceCache) {
|
||||||
|
deviceCache = new Map<string, GPUTexture>();
|
||||||
|
textureCache.set(device, deviceCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = deviceCache.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached.createView();
|
return cached.createView();
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +52,7 @@ export const generateNoise = ({
|
||||||
entryPoint: 'fragment',
|
entryPoint: 'fragment',
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: 'rgba16float',
|
format: NOISE_TEXTURE_FORMAT,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -60,7 +67,7 @@ export const generateNoise = ({
|
||||||
height,
|
height,
|
||||||
depthOrArrayLayers: 1,
|
depthOrArrayLayers: 1,
|
||||||
},
|
},
|
||||||
format: 'rgba16float',
|
format: NOISE_TEXTURE_FORMAT,
|
||||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -84,6 +91,6 @@ export const generateNoise = ({
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
device.queue.submit([commandEncoder.finish()]);
|
device.queue.submit([commandEncoder.finish()]);
|
||||||
textureCache.set(cacheKey, colorTexture);
|
deviceCache.set(cacheKey, colorTexture);
|
||||||
return colorTexture.createView();
|
return colorTexture.createView();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export class ResizableTexture {
|
||||||
size: vec2
|
size: vec2
|
||||||
) {
|
) {
|
||||||
this.copyPipeline = new CopyPipeline(this.device);
|
this.copyPipeline = new CopyPipeline(this.device);
|
||||||
this.size = size;
|
this.size = vec2.clone(size);
|
||||||
this.texture = this.createTexture(size);
|
this.texture = this.createTexture(size);
|
||||||
this.textureView = this.texture.createView();
|
this.textureView = this.texture.createView();
|
||||||
}
|
}
|
||||||
|
|
@ -36,11 +36,15 @@ export class ResizableTexture {
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
this.texture.destroy();
|
this.texture.destroy();
|
||||||
|
|
||||||
this.size = size;
|
this.size = vec2.clone(size);
|
||||||
this.texture = newTexture;
|
this.texture = newTexture;
|
||||||
this.textureView = newTextureView;
|
this.textureView = newTextureView;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSize(): vec2 {
|
||||||
|
return vec2.clone(this.size);
|
||||||
|
}
|
||||||
|
|
||||||
public getTextureView(): GPUTextureView {
|
public getTextureView(): GPUTextureView {
|
||||||
return this.textureView;
|
return this.textureView;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import browserslist from 'browserslist';
|
import browserslist from 'browserslist';
|
||||||
import { browserslistToTargets } from 'lightningcss';
|
import { browserslistToTargets } from 'lightningcss';
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const cssTargets = browserslistToTargets(browserslist());
|
const cssTargets = browserslistToTargets(browserslist());
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue