@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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/bin/jtk.mjs +41 -0
- package/docs/booking.md +164 -0
- package/docs/catalogue.md +459 -0
- package/docs/collections.md +249 -0
- package/docs/css.md +86 -0
- package/docs/gallery.md +127 -0
- package/docs/hero-motion.md +189 -0
- package/docs/kit.md +454 -0
- package/docs/languages.md +182 -0
- package/docs/lead-form.md +109 -0
- package/docs/pages.md +193 -0
- package/docs/photos.md +314 -0
- package/docs/scaffold.md +75 -0
- package/docs/shapes.md +140 -0
- package/docs/surface.md +187 -0
- package/lib/catalogue.mjs +1678 -0
- package/lib/codes.mjs +171 -0
- package/lib/create.mjs +282 -0
- package/package.json +16 -0
- package/template/astro.config.mjs +84 -0
- package/template/figures.mjs +122 -0
- package/template/gitignore +16 -0
- package/template/jtakeit-meta.mjs +112 -0
- package/template/jtk/content/index.json +38 -0
- package/template/jtk/design.json +24 -0
- package/template/markdown.mjs +36 -0
- package/template/package-lock.json +5320 -0
- package/template/package.json +26 -0
- package/template/specimens.mjs +46 -0
- package/template/src/components/Blocks.astro +151 -0
- package/template/src/components/BookingForm.astro +506 -0
- package/template/src/components/Clip.astro +155 -0
- package/template/src/components/Hero.astro +66 -0
- package/template/src/components/LeadForm.astro +347 -0
- package/template/src/components/OpeningHours.astro +69 -0
- package/template/src/components/Pile.astro +185 -0
- package/template/src/components/Shot.astro +472 -0
- package/template/src/components/gallery/Gallery.astro +381 -0
- package/template/src/components/gallery/galleries.ts +139 -0
- package/template/src/components/motion/HeroField.astro +520 -0
- package/template/src/components/motion/fields.ts +430 -0
- package/template/src/components/surface/Pattern.astro +278 -0
- package/template/src/components/surface/patterns.ts +187 -0
- package/template/src/content/blocks.ts +758 -0
- package/template/src/content.config.ts +19 -0
- package/template/src/copy/LOCALE.ts +324 -0
- package/template/src/data/site.ts +137 -0
- package/template/src/layouts/Layout.astro +282 -0
- package/template/src/lib/alive.ts +49 -0
- package/template/src/lib/entries.ts +106 -0
- package/template/src/lib/entryLoader.ts +315 -0
- package/template/src/lib/noise.ts +26 -0
- package/template/src/lib/page.ts +287 -0
- package/template/src/lib/photos.ts +168 -0
- package/template/src/lib/under.ts +32 -0
- package/template/src/lib/uploads.ts +85 -0
- package/template/src/pages/[...entry].astro +207 -0
- package/template/src/pages/[...feed].xml.ts +64 -0
- package/template/src/pages/index.astro +90 -0
- package/template/src/pages/llms.txt.ts +50 -0
- package/template/src/pages/privacy.astro +59 -0
- package/template/src/pages/robots.txt.ts +21 -0
- package/template/src/pages/sitemap.xml.ts +50 -0
- package/template/src/styles/global.css +411 -0
- package/template/src/styles/surface.css +375 -0
- 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>
|