@jtakeit/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/jtk.mjs +41 -0
  4. package/docs/booking.md +164 -0
  5. package/docs/catalogue.md +459 -0
  6. package/docs/collections.md +249 -0
  7. package/docs/css.md +86 -0
  8. package/docs/gallery.md +127 -0
  9. package/docs/hero-motion.md +189 -0
  10. package/docs/kit.md +454 -0
  11. package/docs/languages.md +182 -0
  12. package/docs/lead-form.md +109 -0
  13. package/docs/pages.md +193 -0
  14. package/docs/photos.md +314 -0
  15. package/docs/scaffold.md +75 -0
  16. package/docs/shapes.md +140 -0
  17. package/docs/surface.md +187 -0
  18. package/lib/catalogue.mjs +1678 -0
  19. package/lib/codes.mjs +171 -0
  20. package/lib/create.mjs +282 -0
  21. package/package.json +16 -0
  22. package/template/astro.config.mjs +84 -0
  23. package/template/figures.mjs +122 -0
  24. package/template/gitignore +16 -0
  25. package/template/jtakeit-meta.mjs +112 -0
  26. package/template/jtk/content/index.json +38 -0
  27. package/template/jtk/design.json +24 -0
  28. package/template/markdown.mjs +36 -0
  29. package/template/package-lock.json +5320 -0
  30. package/template/package.json +26 -0
  31. package/template/specimens.mjs +46 -0
  32. package/template/src/components/Blocks.astro +151 -0
  33. package/template/src/components/BookingForm.astro +506 -0
  34. package/template/src/components/Clip.astro +155 -0
  35. package/template/src/components/Hero.astro +66 -0
  36. package/template/src/components/LeadForm.astro +347 -0
  37. package/template/src/components/OpeningHours.astro +69 -0
  38. package/template/src/components/Pile.astro +185 -0
  39. package/template/src/components/Shot.astro +472 -0
  40. package/template/src/components/gallery/Gallery.astro +381 -0
  41. package/template/src/components/gallery/galleries.ts +139 -0
  42. package/template/src/components/motion/HeroField.astro +520 -0
  43. package/template/src/components/motion/fields.ts +430 -0
  44. package/template/src/components/surface/Pattern.astro +278 -0
  45. package/template/src/components/surface/patterns.ts +187 -0
  46. package/template/src/content/blocks.ts +758 -0
  47. package/template/src/content.config.ts +19 -0
  48. package/template/src/copy/LOCALE.ts +324 -0
  49. package/template/src/data/site.ts +137 -0
  50. package/template/src/layouts/Layout.astro +282 -0
  51. package/template/src/lib/alive.ts +49 -0
  52. package/template/src/lib/entries.ts +106 -0
  53. package/template/src/lib/entryLoader.ts +315 -0
  54. package/template/src/lib/noise.ts +26 -0
  55. package/template/src/lib/page.ts +287 -0
  56. package/template/src/lib/photos.ts +168 -0
  57. package/template/src/lib/under.ts +32 -0
  58. package/template/src/lib/uploads.ts +85 -0
  59. package/template/src/pages/[...entry].astro +207 -0
  60. package/template/src/pages/[...feed].xml.ts +64 -0
  61. package/template/src/pages/index.astro +90 -0
  62. package/template/src/pages/llms.txt.ts +50 -0
  63. package/template/src/pages/privacy.astro +59 -0
  64. package/template/src/pages/robots.txt.ts +21 -0
  65. package/template/src/pages/sitemap.xml.ts +50 -0
  66. package/template/src/styles/global.css +411 -0
  67. package/template/src/styles/surface.css +375 -0
  68. package/template/tsconfig.json +5 -0
@@ -0,0 +1,520 @@
1
+ ---
2
+ /**
3
+ * The hero's moving ground.
4
+ *
5
+ * One canvas behind the hero, painted in the variant's own tokens. It is the
6
+ * page's single "wow" moment — the thing that makes a landing look built rather
7
+ * than assembled — and it is written so that it can fail completely without
8
+ * anybody noticing:
9
+ *
10
+ * no JavaScript · no WebGL · reduced motion · off screen · a lost context
11
+ *
12
+ * all end at the same place, a still ground drawn in CSS from the same colours.
13
+ * Nothing readable is ever inside the canvas.
14
+ *
15
+ * Ten fields ship, and they are not ten skins of one look — pick from the
16
+ * design, not by taste. What each one is for is in the table in
17
+ * `docs/hero-motion.md` of @jtakeit/astro; the shaders themselves are
18
+ * in ./fields.ts.
19
+ *
20
+ * **`colors` and `ground` have no defaults, on purpose.** A field in somebody
21
+ * else's colours is the loudest tell that a page was generated, and a default
22
+ * palette is how that happens — nobody ever goes back to change one. The
23
+ * component does not compile until four colours and a ground have been chosen
24
+ * for *this* business, from their photographs, their room and their work:
25
+ * `PORTRAIT.md` or `BRIEF.md` has them under Colour and material.
26
+ *
27
+ * Any CSS value works, tokens included, and tokens are the better answer when
28
+ * the variant's own palette already came off their photographs.
29
+ *
30
+ * <HeroField
31
+ * field="glow"
32
+ * ground="#0b0705"
33
+ * colors={['#3b1d0e', '#ff8a3d', '#ffd8a8', '#120a06']}
34
+ * scrim
35
+ * >
36
+ * <h1>…</h1>
37
+ * </HeroField>
38
+ */
39
+ import type { Field } from './fields';
40
+
41
+ interface Props {
42
+ /**
43
+ * drift · aurora · plasma · glow · flow · silk · smoke · riso · veil ·
44
+ * ribbons. A design decision, not a taste one — see the table in
45
+ * hero-motion.md, which also has the two pairs that are close enough to need
46
+ * telling apart.
47
+ */
48
+ field?: Field;
49
+ /**
50
+ * Exactly four colours, in any CSS form, taken from this business rather than
51
+ * from a shader gallery. Required: there is no sensible default for "their
52
+ * colours", and a field that ships in the kit's blue is a field that says the
53
+ * page was assembled.
54
+ */
55
+ colors: [string, string, string, string] | string[];
56
+ /**
57
+ * The ground the field is mixed into, and the colour the CSS fallback paints.
58
+ * Required for the same reason. `glow`, `aurora`, `veil` and `ribbons` want a
59
+ * dark one — `veil` shows it through the curtain, so it is looked at rather
60
+ * than merely under things; `drift`, `silk`, `flow` and `riso` are happy on
61
+ * paper. `smoke` wants a palette with a real dark in it, whichever way the
62
+ * ground goes.
63
+ */
64
+ ground: string;
65
+ /** Multiplies the field's own pace. Below 1 is slower; 2 is already restless. */
66
+ speed?: number;
67
+ /** Film grain, 0–1. A little of it is what stops a gradient looking like CSS. */
68
+ grain?: number;
69
+ /**
70
+ * Let the field lean toward the pointer. `ribbons` only, off by default: a
71
+ * background that chases the mouse is a background that is being looked at
72
+ * instead of the words.
73
+ */
74
+ pointer?: boolean;
75
+ /**
76
+ * Lay a gradient between the field and the content. Set it whenever text
77
+ * sits on top: a field moves, and contrast that holds in one frame does not
78
+ * hold in all of them.
79
+ */
80
+ scrim?: boolean;
81
+ class?: string;
82
+ }
83
+
84
+ const {
85
+ field = 'drift',
86
+ colors,
87
+ ground,
88
+ speed = 1,
89
+ grain = 0.35,
90
+ pointer = false,
91
+ scrim = false,
92
+ class: className,
93
+ } = Astro.props;
94
+
95
+ /*
96
+ * Four, not "up to four". Three colours passed by hand means the fourth is
97
+ * whatever the shader's fallback happens to be, which is the generated look
98
+ * this component exists to avoid — and it is worth failing the build over,
99
+ * because a warning in a build log is a warning nobody reads.
100
+ */
101
+ if (!Array.isArray(colors) || colors.filter(Boolean).length < 4) {
102
+ throw new Error(
103
+ `<HeroField field="${field}"> needs four colours chosen for this business — ` +
104
+ 'their photographs, their room, their work. See Colour and material in ' +
105
+ 'PORTRAIT.md or BRIEF.md, and hero-motion.md for what each field wants.',
106
+ );
107
+ }
108
+
109
+ const vars = [
110
+ ...colors.slice(0, 4).map((c, i) => `--field-${i + 1}:${c}`),
111
+ `--field-ground:${ground}`,
112
+ ].join(';');
113
+ ---
114
+
115
+ <div
116
+ class:list={['field', className]}
117
+ style={vars}
118
+ data-hero-field={field}
119
+ data-speed={String(speed)}
120
+ data-grain={String(grain)}
121
+ data-pointer={pointer ? '' : undefined}
122
+ >
123
+ <canvas class="field__canvas" aria-hidden="true"></canvas>
124
+ {scrim && <div class="field__scrim" aria-hidden="true"></div>}
125
+ <div class="field__content"><slot /></div>
126
+ </div>
127
+
128
+ <style>
129
+ /*
130
+ * The CSS ground. This is not a loading state — on a phone with WebGL
131
+ * disabled, in a screenshot taken by a headless browser, and for anyone who
132
+ * asked for less motion, it is the hero. It has to look deliberate on its
133
+ * own, which is why it is built from the same four colours rather than from
134
+ * a grey.
135
+ */
136
+ .field {
137
+ position: relative;
138
+ isolation: isolate;
139
+ overflow: hidden;
140
+ background-color: var(--field-ground, var(--paper, #fff));
141
+ background-image:
142
+ radial-gradient(120% 90% at 12% 18%, var(--field-1, var(--accent)) 0%, transparent 62%),
143
+ radial-gradient(100% 80% at 88% 26%, var(--field-2, var(--accent-ink)) 0%, transparent 58%),
144
+ radial-gradient(110% 90% at 50% 108%, var(--field-3, var(--paper-deep)) 0%, transparent 64%);
145
+ }
146
+
147
+ .field__canvas {
148
+ position: absolute;
149
+ inset: 0;
150
+ display: block;
151
+ inline-size: 100%;
152
+ block-size: 100%;
153
+ /* Faded in after the first frame, so the swap from the CSS ground to the
154
+ canvas is not a flash of a different picture. */
155
+ opacity: 0;
156
+ transition: opacity 600ms var(--ease, ease);
157
+ }
158
+
159
+ .field[data-state='live'] .field__canvas {
160
+ opacity: 1;
161
+ }
162
+
163
+ .field__scrim {
164
+ position: absolute;
165
+ inset: 0;
166
+ background: linear-gradient(
167
+ to bottom,
168
+ color-mix(in srgb, var(--field-ground, var(--paper)) 70%, transparent),
169
+ color-mix(in srgb, var(--field-ground, var(--paper)) 35%, transparent)
170
+ );
171
+ }
172
+
173
+ .field__content {
174
+ position: relative;
175
+ z-index: 1;
176
+ block-size: 100%;
177
+ }
178
+ </style>
179
+
180
+ <script>
181
+ /*
182
+ * One module for every field on the page. It owns the canvas, the palette,
183
+ * the sizing budget and the loop; the two renderers below own what is drawn.
184
+ * Every path out of here is the same path: leave the canvas transparent and
185
+ * let the CSS ground stand.
186
+ */
187
+ import { FRAGMENT, VERTEX, type Field, type ShaderField } from './fields';
188
+
189
+ type Colours = { palette: Float32Array; ground: [number, number, number] };
190
+
191
+ interface Renderer {
192
+ /** Canvas pixels, and how many of them go to one CSS pixel. */
193
+ resize(width: number, height: number, scale: number): void;
194
+ draw(time: number, dt: number, colours: Colours): void;
195
+ /** Listeners a renderer added for itself. */
196
+ dispose?(): void;
197
+ }
198
+
199
+ /*
200
+ * Colours arrive as whatever the token holds — a hex, an rgb(), a
201
+ * color-mix(). A 2D context normalises every one of them, which is cheaper
202
+ * and more complete than parsing CSS by hand.
203
+ */
204
+ const probe = document.createElement('canvas').getContext('2d');
205
+
206
+ function toRgb(value: string, fallback: [number, number, number]): [number, number, number] {
207
+ const text = value.trim();
208
+ if (!probe || !text) return fallback;
209
+ probe.fillStyle = '#000000';
210
+ probe.fillStyle = text;
211
+ const normalised = probe.fillStyle as string;
212
+ const hex = /^#([0-9a-f]{6})$/i.exec(normalised);
213
+ if (hex) {
214
+ const n = parseInt(hex[1], 16);
215
+ return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
216
+ }
217
+ const rgb = /rgba?\(([^)]+)\)/.exec(normalised);
218
+ if (rgb) {
219
+ const parts = rgb[1].split(/[\s,/]+/).filter(Boolean).map(Number);
220
+ if (parts.length >= 3) return [parts[0] / 255, parts[1] / 255, parts[2] / 255];
221
+ }
222
+ return fallback;
223
+ }
224
+
225
+ const css = (rgb: [number, number, number], alpha: number) =>
226
+ `rgba(${Math.round(rgb[0] * 255)}, ${Math.round(rgb[1] * 255)}, ${Math.round(rgb[2] * 255)}, ${alpha})`;
227
+
228
+ /** One fullscreen fragment shader. Four of the five fields are this. */
229
+ function shaderRenderer(canvas: HTMLCanvasElement, source: string, grain: number): Renderer | null {
230
+ // preserveDrawingBuffer: the pitch video photographs the page rather than
231
+ // filming it, and a buffer discarded after each frame photographs blank.
232
+ const gl = canvas.getContext('webgl', {
233
+ alpha: false,
234
+ antialias: false,
235
+ depth: false,
236
+ stencil: false,
237
+ powerPreference: 'low-power',
238
+ preserveDrawingBuffer: true,
239
+ });
240
+ if (!gl) return null;
241
+
242
+ const compile = (type: number, src: string) => {
243
+ const shader = gl.createShader(type);
244
+ if (!shader) return null;
245
+ gl.shaderSource(shader, src);
246
+ gl.compileShader(shader);
247
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
248
+ console.warn('hero field:', gl.getShaderInfoLog(shader));
249
+ return null;
250
+ }
251
+ return shader;
252
+ };
253
+
254
+ const vertex = compile(gl.VERTEX_SHADER, VERTEX);
255
+ const fragment = compile(gl.FRAGMENT_SHADER, source);
256
+ const program = gl.createProgram();
257
+ if (!vertex || !fragment || !program) return null;
258
+ gl.attachShader(program, vertex);
259
+ gl.attachShader(program, fragment);
260
+ gl.linkProgram(program);
261
+ gl.deleteShader(vertex);
262
+ gl.deleteShader(fragment);
263
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
264
+ gl.useProgram(program);
265
+
266
+ // One triangle covering the viewport. A quad would need two.
267
+ const buffer = gl.createBuffer();
268
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
269
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
270
+ const position = gl.getAttribLocation(program, 'a_position');
271
+ gl.enableVertexAttribArray(position);
272
+ gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
273
+
274
+ const uniform = {
275
+ resolution: gl.getUniformLocation(program, 'u_resolution'),
276
+ time: gl.getUniformLocation(program, 'u_time'),
277
+ grain: gl.getUniformLocation(program, 'u_grain'),
278
+ // Some drivers only answer to the indexed name.
279
+ colors: gl.getUniformLocation(program, 'u_colors[0]') ?? gl.getUniformLocation(program, 'u_colors'),
280
+ bg: gl.getUniformLocation(program, 'u_bg'),
281
+ };
282
+
283
+ return {
284
+ resize(width, height) {
285
+ gl.viewport(0, 0, width, height);
286
+ },
287
+ draw(time, _dt, colours) {
288
+ gl.uniform2f(uniform.resolution, canvas.width, canvas.height);
289
+ gl.uniform1f(uniform.time, time);
290
+ gl.uniform1f(uniform.grain, grain);
291
+ gl.uniform3fv(uniform.colors, colours.palette);
292
+ gl.uniform3f(uniform.bg, colours.ground[0], colours.ground[1], colours.ground[2]);
293
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
294
+ },
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Ribbons: stacked sine curves with a noise term, drawn as lines rather than
300
+ * as a colour field. Canvas 2D on purpose — it is the member of the set that
301
+ * works where WebGL does not, and line art is a different job from light.
302
+ */
303
+ function ribbonRenderer(canvas: HTMLCanvasElement, follows: boolean): Renderer | null {
304
+ const ctx = canvas.getContext('2d', { alpha: false });
305
+ if (!ctx) return null;
306
+
307
+ let width = 1;
308
+ let height = 1;
309
+ // Where the pointer is, and where the drawing has caught up to. The lag is
310
+ // the whole effect: an instant response reads as a jitter.
311
+ let targetX = 0;
312
+ let pointerX = 0;
313
+ const onPointerMove = (event: PointerEvent) => {
314
+ const box = canvas.getBoundingClientRect();
315
+ if (!box.width) return;
316
+ targetX = ((event.clientX - box.left) / box.width - 0.5) * 2;
317
+ };
318
+ const onPointerLeave = () => {
319
+ targetX = 0;
320
+ };
321
+ if (follows) {
322
+ window.addEventListener('pointermove', onPointerMove, { passive: true });
323
+ window.addEventListener('pointerleave', onPointerLeave);
324
+ window.addEventListener('blur', onPointerLeave);
325
+ }
326
+
327
+ const wobble = (x: number, t: number, offset: number) =>
328
+ (Math.sin(x * 0.0012 + t * 0.25 + offset) + Math.cos(x * 0.0028 - t * 0.4 + offset * 2)) / 2;
329
+
330
+ const LAYERS = [
331
+ { count: 14, step: 6, amplitude: 55, pace: 1.1, weight: 1.4, lead: true },
332
+ { count: 9, step: 8, amplitude: 30, pace: 0.7, weight: 0.8, lead: false },
333
+ ];
334
+
335
+ return {
336
+ resize(pixelWidth, pixelHeight, scale) {
337
+ // Geometry is written in CSS pixels; the transform carries the device
338
+ // ratio, so the same numbers work on a 1× monitor and a 3× phone.
339
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
340
+ width = pixelWidth / scale;
341
+ height = pixelHeight / scale;
342
+ },
343
+ draw(time, dt, colours) {
344
+ pointerX += (targetX - pointerX) * (1 - Math.exp(-6 * dt));
345
+
346
+ ctx.fillStyle = css(colours.ground, 1);
347
+ ctx.fillRect(0, 0, width, height);
348
+
349
+ const ramp = ctx.createLinearGradient(0, 0, width, 0);
350
+ ramp.addColorStop(0, css(colours.ground, 0.15));
351
+ ramp.addColorStop(0.5, css([colours.palette[0], colours.palette[1], colours.palette[2]], 0.9));
352
+ ramp.addColorStop(1, css([colours.palette[3], colours.palette[4], colours.palette[5]], 0.2));
353
+
354
+ for (const layer of LAYERS) {
355
+ // The stack is centred rather than pinned near the top: a hero is any
356
+ // height, and a band that starts at 24% of it hangs off a short one.
357
+ const gap = height * 0.034;
358
+ const top = (height - gap * (layer.count - 1)) / 2 + (layer.lead ? 0 : gap * 0.4);
359
+ for (let r = 0; r < layer.count; r++) {
360
+ const progress = r / layer.count;
361
+ const base = top + r * gap;
362
+ ctx.beginPath();
363
+ for (let x = 0; x <= width + layer.step; x += layer.step) {
364
+ // Pinned at both edges: a ribbon that ends mid-swing looks cut.
365
+ const envelope = Math.sin((x / width) * Math.PI);
366
+ const frequency = 1 + wobble(x, time, progress) * 0.18;
367
+ const amplitude = 1 + wobble(x * 2, -time, progress * 0.5) * 0.15;
368
+ const lift =
369
+ Math.sin(x * 0.0035 * frequency + time * layer.pace + r * 0.18) *
370
+ (layer.amplitude * envelope * amplitude) +
371
+ Math.cos(x * 0.008 - time * 0.7 + r * 0.1) * (20 * envelope) +
372
+ Math.sin(x * 0.018 + time * 1.4) * (8 * envelope);
373
+ const near = Math.exp(
374
+ -Math.pow(Math.abs(x - (0.5 + pointerX * 0.5) * width) / (layer.lead ? 380 : 220), 2),
375
+ );
376
+ const lean = Math.sin(x * 0.015 + time * 2.6) * near * (layer.lead ? 46 : 22) * envelope;
377
+ const y = base + lift + lean;
378
+ x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
379
+ }
380
+ ctx.globalAlpha = (1 - progress * 0.75) * (layer.lead ? 0.8 : 0.45);
381
+ ctx.strokeStyle = ramp;
382
+ ctx.lineWidth = layer.weight + (1 - progress) * 0.5;
383
+ ctx.stroke();
384
+ }
385
+ }
386
+ ctx.globalAlpha = 1;
387
+ },
388
+ dispose() {
389
+ if (!follows) return;
390
+ window.removeEventListener('pointermove', onPointerMove);
391
+ window.removeEventListener('pointerleave', onPointerLeave);
392
+ window.removeEventListener('blur', onPointerLeave);
393
+ },
394
+ };
395
+ }
396
+
397
+ function setup(host: HTMLElement) {
398
+ const canvas = host.querySelector<HTMLCanvasElement>('.field__canvas');
399
+ if (!canvas) return;
400
+
401
+ const kind = (host.dataset.heroField ?? 'drift') as Field;
402
+ const speed = Number(host.dataset.speed) || 1;
403
+ const grain = Number(host.dataset.grain ?? '0.35') || 0;
404
+
405
+ const renderer =
406
+ kind === 'ribbons'
407
+ ? ribbonRenderer(canvas, host.dataset.pointer !== undefined)
408
+ : shaderRenderer(canvas, FRAGMENT[kind as ShaderField] ?? FRAGMENT.drift, grain);
409
+ if (!renderer) return;
410
+
411
+ const DEFAULTS: [number, number, number][] = [
412
+ [0.18, 0.36, 1],
413
+ [0.11, 0.25, 0.84],
414
+ [0.96, 0.95, 0.93],
415
+ [0.09, 0.07, 0.06],
416
+ ];
417
+ const colours = { palette: new Float32Array(12), ground: [1, 1, 1] as [number, number, number] };
418
+
419
+ const readPalette = () => {
420
+ const style = getComputedStyle(host);
421
+ for (let i = 0; i < 4; i++) {
422
+ colours.palette.set(toRgb(style.getPropertyValue(`--field-${i + 1}`), DEFAULTS[i]), i * 3);
423
+ }
424
+ colours.ground = toRgb(style.getPropertyValue('--field-ground'), [1, 1, 1]);
425
+ };
426
+
427
+ const resize = () => {
428
+ const rect = host.getBoundingClientRect();
429
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
430
+ const rawWidth = Math.max(1, Math.round(rect.width * dpr));
431
+ const rawHeight = Math.max(1, Math.round(rect.height * dpr));
432
+ // A field is fill-rate bound: a 3× phone at full resolution costs more
433
+ // than the effect is worth, and nobody can see the difference.
434
+ const budget = Math.min(1, Math.sqrt(2_000_000 / Math.max(1, rawWidth * rawHeight)));
435
+ const width = Math.max(1, Math.round(rawWidth * budget));
436
+ const height = Math.max(1, Math.round(rawHeight * budget));
437
+ if (canvas.width !== width || canvas.height !== height) {
438
+ canvas.width = width;
439
+ canvas.height = height;
440
+ }
441
+ renderer.resize(width, height, Math.max(0.1, width / Math.max(1, rect.width)));
442
+ };
443
+
444
+ let elapsed = 0;
445
+ let painted = false;
446
+
447
+ const draw = (dt: number) => {
448
+ renderer.draw(elapsed, dt, colours);
449
+ if (!painted) {
450
+ painted = true;
451
+ host.dataset.state = 'live';
452
+ }
453
+ };
454
+
455
+ const still = matchMedia('(prefers-reduced-motion: reduce)');
456
+ let raf = 0;
457
+ let last = 0;
458
+ let inView = true;
459
+
460
+ const frame = (now: number) => {
461
+ raf = 0;
462
+ // Clamped, so a tab that was in the background for a minute does not
463
+ // resume a minute further into the animation.
464
+ const dt = Math.min((now - last) / 1000, 0.1);
465
+ last = now;
466
+ elapsed += dt * speed;
467
+ draw(dt);
468
+ raf = requestAnimationFrame(frame);
469
+ };
470
+
471
+ const start = () => {
472
+ if (raf || still.matches || !inView || document.hidden) return;
473
+ last = performance.now();
474
+ raf = requestAnimationFrame(frame);
475
+ };
476
+
477
+ const stop = () => {
478
+ if (raf) cancelAnimationFrame(raf);
479
+ raf = 0;
480
+ };
481
+
482
+ readPalette();
483
+ resize();
484
+ // Reduced motion means the finished state, not a missing one: the field is
485
+ // painted once and then left alone.
486
+ draw(0);
487
+ start();
488
+
489
+ new IntersectionObserver(([entry]) => {
490
+ inView = entry?.isIntersecting ?? true;
491
+ inView ? start() : stop();
492
+ }).observe(host);
493
+
494
+ new ResizeObserver(() => {
495
+ resize();
496
+ if (!raf) draw(0);
497
+ }).observe(host);
498
+
499
+ // The studio bar drives --accent live, and a variant may swap the palette
500
+ // on a class. Either way the field follows rather than keeping yesterday's
501
+ // colour.
502
+ new MutationObserver(() => {
503
+ readPalette();
504
+ if (!raf) draw(0);
505
+ }).observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
506
+
507
+ document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
508
+ still.addEventListener('change', () => (still.matches ? stop() : start()));
509
+ canvas.addEventListener('webglcontextlost', (event) => {
510
+ event.preventDefault();
511
+ stop();
512
+ renderer.dispose?.();
513
+ // Back to the CSS ground rather than to a black rectangle.
514
+ painted = false;
515
+ host.removeAttribute('data-state');
516
+ });
517
+ }
518
+
519
+ document.querySelectorAll<HTMLElement>('[data-hero-field]').forEach(setup);
520
+ </script>