Refresh app shell and site assets

This commit is contained in:
Andras Schmelczer 2026-05-24 10:57:30 +01:00
parent 3c21291d72
commit e54bddc7db
23 changed files with 358 additions and 130 deletions

View file

@ -1,15 +1,14 @@
# Just a bunch of blobs
# Fleeting Garden
[![Deploy to GitHub Pages](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml/badge.svg)](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 an internal render buffer snapshot.
## 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
## Testing
Check out the [agent's logic](./src/pipelines/agents/agent.wgsl).
- `npm test` runs the Vitest unit suite.
- `npm run test:e2e` runs the Playwright Chromium smoke test. The Playwright
config builds the production bundle before serving it.
- `npx playwright install chromium` installs the local browser binary when needed.

View file

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21v-4a4 4 0 1 1 4 4h-4" />
<path d="M21 3a16 16 0 0 0 -12.8 10.2" />
<path d="M21 3a16 16 0 0 1 -10.2 12.8" />
<path d="M10.6 9a9 9 0 0 1 4.4 4.4" />
</svg>

Before

Width:  |  Height:  |  Size: 332 B

10
assets/icons/download.svg Normal file
View 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="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="8" x2="12.01" y2="8" />

Before

Width:  |  Height:  |  Size: 344 B

After

Width:  |  Height:  |  Size: 349 B

Before After
Before After

View file

@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 382 B

Before After
Before After

View file

@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
<path d="M15 5v2a2 2 0 0 0 2 2h2" />
<path d="M5 15h2a2 2 0 0 1 2 2v2" />
<path d="M5 9h2a2 2 0 0 0 2 -2v-2" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 382 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 331 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" />

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 541 B

Before After
Before After

3
assets/icons/sound.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<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: 391 B

View file

@ -6,76 +6,243 @@
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#b7455e" />
<meta name="theme-color" content="#10151f" />
<meta name="robots" content="index,follow" />
<meta name="author" content="Andras Schmelczer" />
<meta
name="description"
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<meta property="og:title" content="Just a bunch of blobs" />
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
<meta property="og:title" content="Fleeting Garden" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Fleeting Garden" />
<meta property="og:locale" content="en_US" />
<meta
property="og:description"
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<meta property="og:url" content="https://schmelczer.dev" />
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
<meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" />
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Fleeting Garden social preview image." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Fleeting Garden" />
<meta
name="twitter:description"
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
<title>Just a bunch of blobs</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
"browserRequirements": "Requires a browser with WebGPU support.",
"author": {
"@type": "Person",
"name": "Andras Schmelczer"
},
"sameAs": "https://github.com/schmelczer/webgpu"
}
</script>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180x180.png" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Fleeting Garden" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fleeting Garden</title>
</head>
<body>
<body class="is-loading">
<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, export,
adjust the config overlay, restart, or open more information.
</p>
<div class="garden-grain" aria-hidden="true"></div>
<div class="eraser-preview" aria-hidden="true"></div>
<div class="garden-prompt" aria-live="polite"></div>
<div class="loading-indicator" role="status">
<div class="splash" data-visible="true">
<h1 class="splash-title">Fleeting Garden</h1>
<p class="splash-description">
Tend it while you can. The garden returns to weather either way.
</p>
<button class="start-button" type="button" disabled>Start</button>
</div>
<div class="loading-bar" data-visible="false" aria-hidden="true" inert>
<div class="loading-status">Starting up&hellip;</div>
<div
class="loading-progress"
role="progressbar"
aria-label="Loading Fleeting Garden"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
></div>
</div>
</div>
<section class="errors-container">
<noscript>JavaScript is required for this website.</noscript>
</section>
</main>
<aside>
<nav class="buttons">
<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">
<aside class="control-dock">
<section
id="info-panel"
class="hidden info-page"
role="region"
aria-label="About panel"
aria-hidden="true"
tabindex="-1"
inert
>
<section>
<h1>Just a bunch of blobs</h1>
<h1>Fleeting Garden</h1>
<p>
A million autonomous agents wander a 2D field. Each one lays down a faint
trail and follows trails it senses ahead. Two generations are competing for
territory: the older one fades, the newer one spreads.
A garden is what we tend; the wild is what we get the moment we look away.
Both happen here at once. Your strokes plant colour, small agents follow them,
branch off, and slowly rewrite the patch you laid down into something you
didn't quite plan.
</p>
<p>
Drag your finger or mouse anywhere on the canvas to paint a wall. Walls slow
the new generation down and let the old one breathe a little longer. Open
<em>Settings</em> to retune sensors, decay rates and aggression.
Three swatches plant the line. The eraser carves a clearing. The mirror folds
one gesture into many, like footpaths around a hidden well.
</p>
<p>
Runs entirely on your GPU via WebGPU compute shaders &mdash; no servers, no
tracking, no analytics. Source on
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
>GitHub</a
Switch vibes to change the season; your shapes stay, the light moves. Add or
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
want to keep one particular instant of weather.
</p>
<p>
Built with WebGPU, running locally in your browser. More of my work at
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
>schmelczer.dev</a
>.
</p>
</section>
</main>
</section>
<main class="pages hidden settings-page">
<section>
<div class="settings-content"></div>
<button id="apply-defaults" class="large-button">Apply defaults</button>
</section>
</main>
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
<button
class="previous-vibe vibe-button"
aria-label="Previous vibe"
title="Previous vibe"
>
&lsaquo;
</button>
<div class="toolbar-shell">
<section class="garden-controls" aria-label="Garden controls">
<div class="swatches" role="group" 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" aria-label="Eraser size" />
</label>
<label class="mirror-segment-control" title="Mirror off">
<input
class="mirror-segment-slider"
type="range"
aria-label="Mirror segments"
/>
</label>
</div>
</section>
</div>
<nav class="buttons" aria-label="App controls">
<button
class="info"
data-control="info"
aria-label="About"
aria-controls="info-panel"
aria-expanded="false"
title="About"
></button>
<button
class="full-screen-toggle"
data-control="full-screen"
aria-label="Enter fullscreen"
title="Enter fullscreen"
></button>
<button
class="settings"
data-control="settings"
aria-label="Show config overlay"
aria-expanded="false"
title="Show config overlay"
></button>
<div class="audio-control">
<button
class="sound"
data-control="sound"
aria-label="Mute audio"
aria-pressed="false"
title="Mute audio"
></button>
<label class="volume-control" title="Master volume">
<input class="volume-slider" type="range" aria-label="Master volume" />
</label>
</div>
<button
class="export-4k"
data-control="export"
aria-label="Download internal buffer snapshot"
title="Download internal buffer snapshot"
></button>
<span class="export-status" aria-live="polite"></span>
<button
class="restart"
data-control="restart"
aria-label="Restart simulation"
title="Restart simulation"
></button>
</nav>
<button class="next-vibe vibe-button" aria-label="Next vibe" title="Next vibe">
&rsaquo;
</button>
</div>
</aside>
<script type="module" src="/src/index.ts"></script>
</body>

View file

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Not found</title>
<meta name="theme-color" content="#b7455e" />
<meta name="viewport" content="initial-scale=1.0" />
<style>
html,
body {
height: 100%;
}
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #b7455e;
}
div {
text-align: center;
}
h1,
a {
font-family: 'Roboto', 'Helvetica Neue', sans-serif;
font-weight: 100;
font-size: 3rem;
color: white;
padding: 2rem;
}
</style>
</head>
<body>
<div>
<h1>Page not found.</h1>
<a href="/">Go back</a>
</div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 892 B

Before After
Before After

View file

@ -1,6 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#b7455e" />
<circle cx="22" cy="26" r="9" fill="#fff" opacity="0.95" />
<circle cx="42" cy="32" r="11" fill="#fff" opacity="0.85" />
<circle cx="28" cy="44" r="7" fill="#fff" opacity="0.75" />
<defs>
<clipPath id="icon-clip">
<rect width="64" height="64" rx="14" />
</clipPath>
</defs>
<g clip-path="url(#icon-clip)">
<rect width="64" height="64" fill="#10151f" />
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#40d6c8" />
<path
d="M32 34c1.2-7.2 4.8-12.3 10-16"
fill="none"
stroke="#10151f"
stroke-linecap="round"
stroke-width="8"
/>
<path
d="M32 34c1.2-7.2 4.8-12.3 10-16"
fill="none"
stroke="#ff5da2"
stroke-linecap="round"
stroke-width="4"
/>
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff5da2" />
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff5da2" />
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 950 B

Before After
Before After

View file

@ -1,38 +1,35 @@
{
"name": "Just a bunch of blobs",
"short_name": "Blobs",
"description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.",
"start_url": "/",
"scope": "/",
"name": "Fleeting Garden",
"short_name": "Garden",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"start_url": "./",
"scope": "./",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#b7455e",
"theme_color": "#b7455e",
"background_color": "#10151f",
"theme_color": "#10151f",
"icons": [
{
"src": "/favicon.svg",
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
"type": "image/svg+xml"
},
{
"src": "/pwa-64x64.png",
"src": "pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/pwa-192x192.png",
"src": "pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/pwa-512x512.png",
"src": "pwa-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/maskable-icon-512x512.png",
"src": "maskable-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 690 B

Before After
Before After

View file

@ -1,2 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://schmelczer.dev/fleeting/sitemap.xml

6
public/sitemap.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://schmelczer.dev/fleeting/</loc>
</url>
</urlset>

68
src/analytics.ts Normal file
View file

@ -0,0 +1,68 @@
import {
init as plausibleInit,
track as plausibleTrack,
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
import { appConfig } from './config';
import type { VibeId } from './vibes';
let isInitialized = false;
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
try {
plausibleTrack(eventName, options);
} catch (error) {
console.warn(`Could not track analytics event "${eventName}".`, error);
}
};
export const initAnalytics = () => {
if (isInitialized) {
return;
}
try {
plausibleInit({
domain: appConfig.analytics.domain,
endpoint: appConfig.analytics.endpoint,
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
logging: appConfig.analytics.logging,
});
isInitialized = true;
} catch (error) {
console.warn('Could not initialize analytics.', error);
}
};
export const trackVibeChange = ({
vibeId,
vibeName,
source,
}: {
vibeId: VibeId;
vibeName: string;
source: string;
}) => {
track('Vibe Change', {
props: {
vibeId,
vibeName,
source,
},
});
};
export const trackStart = () => {
track('Start');
};
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
track('Export', {
props: {
format: 'png',
resolution: 'internal-buffer',
vibeId,
},
});
};