@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,430 @@
1
+ /**
2
+ * The hero fields, as GLSL.
3
+ *
4
+ * Data only — no DOM, no WebGL calls. `HeroField.astro` owns the canvas, the
5
+ * palette, the sizing budget and every path back to the still CSS ground; this
6
+ * file is what each field actually looks like, which is the part worth reading
7
+ * on its own.
8
+ *
9
+ * Adding one is a fragment source plus a row in FRAGMENT. What it has to earn
10
+ * is a *different job* — another variation on soft coloured blobs is the same
11
+ * field with different constants.
12
+ *
13
+ * Every fragment shader is handed the same uniforms:
14
+ *
15
+ * u_resolution canvas pixels
16
+ * u_time seconds, already multiplied by the component's `speed`
17
+ * u_grain 0–1
18
+ * u_colors[4] the variant's tokens, in the order they were passed
19
+ * u_bg the ground colour
20
+ */
21
+
22
+ export type Field =
23
+ | 'drift'
24
+ | 'aurora'
25
+ | 'plasma'
26
+ | 'glow'
27
+ | 'flow'
28
+ | 'silk'
29
+ | 'smoke'
30
+ | 'riso'
31
+ | 'veil'
32
+ | 'ribbons';
33
+
34
+ /** The fields drawn by a fragment shader. `ribbons` is Canvas 2D and is not one. */
35
+ export type ShaderField = Exclude<Field, 'ribbons'>;
36
+
37
+ export const VERTEX = `attribute vec2 a_position;
38
+ void main() { gl_Position = vec4(a_position, 0.0, 1.0); }`;
39
+
40
+ /*
41
+ * highp where it exists, mediump where it does not. A phone GPU without highp
42
+ * in the fragment stage renders the noise as banding rather than failing, so
43
+ * the guard is worth the four lines.
44
+ */
45
+ const HEAD = `#ifdef GL_FRAGMENT_PRECISION_HIGH
46
+ precision highp float;
47
+ #else
48
+ precision mediump float;
49
+ #endif
50
+
51
+ uniform vec2 u_resolution;
52
+ uniform float u_time;
53
+ uniform float u_grain;
54
+ uniform vec3 u_colors[4];
55
+ uniform vec3 u_bg;
56
+
57
+ // Even, unstructured noise for the grain. A multiply hash shows a faint
58
+ // axis-aligned mesh at integer fragment coordinates, which reads as a net.
59
+ float grainHash(vec2 p) {
60
+ vec3 p3 = fract(vec3(p.xyx) * 0.1031);
61
+ p3 += dot(p3, p3.yzx + 33.33);
62
+ return fract((p3.x + p3.y) * p3.z);
63
+ }
64
+ `;
65
+
66
+ /** Value noise: cheap, slightly blocky, right for large soft shapes. */
67
+ const VALUE_NOISE = `
68
+ float hash21(vec2 p) {
69
+ p = fract(p * vec2(234.34, 435.345));
70
+ p += dot(p, p + 34.23);
71
+ return fract(p.x * p.y);
72
+ }
73
+
74
+ float noise(vec2 p) {
75
+ vec2 i = floor(p);
76
+ vec2 f = fract(p);
77
+ vec2 u = f * f * (3.0 - 2.0 * f);
78
+ return mix(
79
+ mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),
80
+ mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x),
81
+ u.y);
82
+ }
83
+
84
+ float fbm(vec2 p) {
85
+ float v = 0.0;
86
+ float a = 0.5;
87
+ for (int i = 0; i < 4; i++) {
88
+ v += a * noise(p);
89
+ p = p * 2.03 + vec2(17.0, 9.2);
90
+ a *= 0.5;
91
+ }
92
+ return v;
93
+ }
94
+ `;
95
+
96
+ /** Simplex noise: smoother and directionless, right for anything flowing. */
97
+ const SIMPLEX = `
98
+ vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
99
+
100
+ float snoise(vec2 v) {
101
+ const vec4 C = vec4(0.211324865405187, 0.366025403784439,
102
+ -0.577350269189626, 0.024390243902439);
103
+ vec2 i = floor(v + dot(v, C.yy));
104
+ vec2 x0 = v - i + dot(i, C.xx);
105
+ vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
106
+ vec4 x12 = x0.xyxy + C.xxzz;
107
+ x12.xy -= i1;
108
+ i = mod(i, 289.0);
109
+ vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
110
+ vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
111
+ m = m * m;
112
+ m = m * m;
113
+ vec3 x = 2.0 * fract(p * C.www) - 1.0;
114
+ vec3 h = abs(x) - 0.5;
115
+ vec3 ox = floor(x + 0.5);
116
+ vec3 a0 = x - ox;
117
+ m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
118
+ vec3 g;
119
+ g.x = a0.x * x0.x + h.x * x0.y;
120
+ g.yz = a0.yz * x12.xz + h.yz * x12.yw;
121
+ return 130.0 * dot(m, g);
122
+ }
123
+ `;
124
+
125
+ /**
126
+ * A ramp through the four colours. WebGL1 forbids indexing a uniform array by
127
+ * a computed value in the fragment stage, so the steps are written out.
128
+ */
129
+ const PALETTE = `
130
+ vec3 palette(float x) {
131
+ float f = clamp(x, 0.0, 1.0) * 3.0;
132
+ vec3 col = u_colors[0];
133
+ col = mix(col, u_colors[1], smoothstep(0.0, 1.0, clamp(f, 0.0, 1.0)));
134
+ col = mix(col, u_colors[2], smoothstep(0.0, 1.0, clamp(f - 1.0, 0.0, 1.0)));
135
+ col = mix(col, u_colors[3], smoothstep(0.0, 1.0, clamp(f - 2.0, 0.0, 1.0)));
136
+ return col;
137
+ }
138
+ `;
139
+
140
+ /** Four lamps of colour on slow orbits through a warped field. */
141
+ const DRIFT = `
142
+ void main() {
143
+ vec2 uv = gl_FragCoord.xy / u_resolution.xy;
144
+ float m = min(u_resolution.x, u_resolution.y);
145
+ // Aspect-corrected, so the field does not stretch with the window.
146
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / m * 1.3;
147
+ float t = u_time;
148
+
149
+ p += 0.15 * vec2(sin(t * 0.31), cos(t * 0.23));
150
+ p += 0.19 * (vec2(fbm(p * 2.0), fbm(p * 2.0 + vec2(5.2, 1.3))) - 0.5);
151
+
152
+ vec3 acc = u_bg * 0.25;
153
+ float total = 0.25;
154
+ for (int i = 0; i < 4; i++) {
155
+ float fi = float(i);
156
+ vec2 c = vec2(
157
+ sin(t * (0.21 + fi * 0.071) + fi * 2.4),
158
+ cos(t * (0.17 + fi * 0.093) + fi * 1.7)) * 0.75;
159
+ float w = exp(-dot(p - c, p - c) * 3.4);
160
+ acc += u_colors[i] * w;
161
+ total += w;
162
+ }
163
+ vec3 col = acc / total;
164
+
165
+ float vd = length(uv - 0.5) * 1.41421356;
166
+ col *= 1.0 - 0.15 * smoothstep(0.35, 1.0, vd);
167
+ col += (grainHash(gl_FragCoord.xy + vec2(t * 17.0)) - 0.5) * u_grain * 0.09;
168
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
169
+ }
170
+ `;
171
+
172
+ /** Three layers of simplex noise riding each other. Weather. */
173
+ const AURORA = `
174
+ void main() {
175
+ vec2 uv = gl_FragCoord.xy / u_resolution.xy;
176
+ vec2 p = uv - 0.5;
177
+ p.x *= u_resolution.x / u_resolution.y;
178
+ float t = u_time * 0.1;
179
+
180
+ // The second and third layers take the one above as an offset, which is what
181
+ // turns noise into weather.
182
+ float n1 = snoise(p * 0.4 + vec2(t * 0.2, -t * 0.3));
183
+ float n2 = snoise(p * 0.55 + vec2(-t * 0.15, t * 0.25) + n1 * 0.25);
184
+ float n3 = snoise(p * 0.75 + vec2(t * 0.1, -t * 0.2) + n2 * 0.2);
185
+
186
+ float dist = length(p) * 1.5;
187
+ float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
188
+
189
+ vec3 col = u_bg;
190
+ col = mix(col, u_colors[0], smoothstep(-0.2, 0.5, n1) * 0.85);
191
+ col = mix(col, u_colors[1], smoothstep(-0.1, 0.6, n2) * 0.70);
192
+ col = mix(col, u_colors[2], smoothstep(-0.3, 0.4, n3) * 0.60);
193
+ col = mix(col, u_colors[3], smoothstep(0.0, 0.7, n1 * n2) * 0.50);
194
+ col += u_colors[1] * smoothstep(0.8, 0.0, dist) * 0.3;
195
+ col = mix(u_bg, col, 0.2 + 0.8 * vignette);
196
+
197
+ col += (grainHash(gl_FragCoord.xy + vec2(u_time * 17.0)) - 0.5) * u_grain * 0.1;
198
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
199
+ }
200
+ `;
201
+
202
+ /**
203
+ * Interference. Four sine fields summed and read through the palette, which
204
+ * produces bands and rings rather than blobs — the one field in the set with
205
+ * visible structure to it.
206
+ */
207
+ const PLASMA = `
208
+ void main() {
209
+ vec2 uv = gl_FragCoord.xy / u_resolution.xy;
210
+ float m = min(u_resolution.x, u_resolution.y);
211
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / m * 1.5;
212
+ float t = u_time;
213
+
214
+ p += 0.32 * vec2(sin(t * 0.31), cos(t * 0.23));
215
+
216
+ // Higher k is more rings in the same frame. Past ~9 a phone shows moiré.
217
+ float k = 7.0;
218
+ float v = sin(p.x * k + t)
219
+ + sin(p.y * k * 0.8 - t * 0.7)
220
+ + sin((p.x + p.y) * k * 0.6 + t * 0.5)
221
+ + sin(length(p) * k * 1.2 - t);
222
+ vec3 col = palette(0.5 + 0.5 * sin(v));
223
+
224
+ float vd = length(uv - 0.5) * 1.41421356;
225
+ col = mix(col, u_bg, 0.61 * smoothstep(0.35, 1.0, vd));
226
+ col += (grainHash(gl_FragCoord.xy + vec2(t * 17.0)) - 0.5) * u_grain * 0.12;
227
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
228
+ }
229
+ `;
230
+
231
+ /**
232
+ * One bright filament of light in a dark room. Almost all of the frame stays
233
+ * near the ground colour, which is what makes the lit part read as light rather
234
+ * than as paint — and what makes this the only field in the set that needs a
235
+ * dark ground to work at all.
236
+ */
237
+ const GLOW = `
238
+ void main() {
239
+ vec2 uv = gl_FragCoord.xy / u_resolution.xy;
240
+ vec2 p = uv * vec2(u_resolution.x / u_resolution.y, 1.0);
241
+ float t = u_time * 0.2;
242
+
243
+ float n1 = snoise(p * 0.5 + t);
244
+ float n2 = snoise(p * 0.9 - t * 0.5 + n1);
245
+ float n3 = snoise(p * 1.6 + t * 0.35 + n2 * 0.5);
246
+ // The power is what thins the lit band into a filament: everything but the
247
+ // ridge of the noise falls away to nothing.
248
+ float light = pow(abs(n2), 2.5) * 0.5;
249
+
250
+ vec3 col = u_bg;
251
+ col += u_colors[0] * smoothstep(0.1, 1.0, n1) * 0.5;
252
+ col += u_colors[1] * light;
253
+ col += u_colors[2] * pow(abs(n3), 4.0) * 0.35;
254
+
255
+ col += (grainHash(gl_FragCoord.xy + vec2(u_time * 17.0)) - 0.5) * u_grain * 0.16;
256
+ // Toward the ground rather than toward black, so a light palette is possible
257
+ // even though this field does not really want one.
258
+ col = mix(u_bg, col, smoothstep(1.2, 0.2, length(uv - 0.5)));
259
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
260
+ }
261
+ `;
262
+
263
+ /**
264
+ * A flow field. An fbm sample decides a direction at every point, and a second
265
+ * one is read along it — which is what turns noise into something that appears
266
+ * to be *moving somewhere*, like ink pulled through water or marbled paper.
267
+ */
268
+ const FLOW = `
269
+ void main() {
270
+ float m = min(u_resolution.x, u_resolution.y);
271
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / m * 1.5;
272
+ float t = u_time;
273
+
274
+ float angle = fbm(p * 2.0 + 1.7) * 6.2831;
275
+ vec2 dir = vec2(cos(angle), sin(angle));
276
+ vec3 col = palette(fbm(p * 3.0 + dir * 1.1 + t * 0.10));
277
+
278
+ col += (grainHash(gl_FragCoord.xy + vec2(t * 17.0)) - 0.5) * u_grain * 0.06;
279
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
280
+ }
281
+ `;
282
+
283
+ /**
284
+ * Silk. Four rounds of feeding the coordinate back through a cosine of itself
285
+ * fold the plane into smooth creases — the softest field in the set, and the
286
+ * one that survives being put behind a lot of text.
287
+ */
288
+ const SILK = `
289
+ void main() {
290
+ float m = min(u_resolution.x, u_resolution.y);
291
+ vec2 q = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / m * 1.5 * 1.6;
292
+ float t = u_time * 0.35;
293
+
294
+ float amp = 0.72;
295
+ for (float i = 1.0; i < 5.0; i += 1.0) {
296
+ q.x += amp / i * cos(i * 2.4 * q.y + t * 0.8 + 1.0);
297
+ q.y += amp / i * cos(i * 1.7 * q.x + t * 0.6);
298
+ }
299
+ vec3 col = palette(0.5 + 0.5 * sin(q.x + q.y));
300
+
301
+ col += (grainHash(gl_FragCoord.xy + vec2(u_time * 17.0)) - 0.5) * u_grain * 0.06;
302
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
303
+ }
304
+ `;
305
+
306
+
307
+ /**
308
+ * Smoke. Two rounds of domain warping — an fbm pair displaces the plane, and a
309
+ * second pair displaces it again — which is what produces volume: plumes that
310
+ * fold over each other and read as depth rather than as a pattern on a wall.
311
+ *
312
+ * The behaviour is the classic Iñigo Quilez warp, by way of the 21st.dev shader
313
+ * builder's "Smoke". What arrived with it was a fixed blue palette, and that is
314
+ * the half that had to go: the ramp is `u_colors` here, so it is their smoke.
315
+ *
316
+ * It is not `flow`. That one is a single flow field — one direction per point,
317
+ * one sample read along it — and it reads as marbling, flat and streaked. This
318
+ * has an inside.
319
+ */
320
+ const SMOKE = `
321
+ void main() {
322
+ float m = min(u_resolution.x, u_resolution.y);
323
+ // Deliberately wider than the flow field. At the same scale the two are the
324
+ // same picture: what separates them is that this has a handful of large
325
+ // plumes crossing the frame, where flow is even everywhere.
326
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / m * 1.1;
327
+ float t = u_time;
328
+
329
+ // How far the second warp pushes. Past ~6 the plumes stop reading as one
330
+ // volume and start reading as noise.
331
+ const float warp = 5.0;
332
+ vec2 q = vec2(fbm(p + t * 0.08), fbm(p + vec2(5.2, 1.3) - t * 0.06));
333
+ vec2 r = vec2(fbm(p + warp * q + vec2(1.7, 9.2)),
334
+ fbm(p + warp * q + vec2(8.3, 2.8)));
335
+ // The contrast goes on the ramp coordinate, not on the finished colour.
336
+ // Two rounds of warping pull fbm hard toward its average, so the field needs
337
+ // spreading — but spreading the *colour* blows the light end of the palette
338
+ // to white, and the centre below 0.5 is what keeps the dark end in the frame.
339
+ vec3 col = palette(clamp((fbm(p + 3.0 * r) - 0.5) * 1.4 + 0.4, 0.0, 1.0));
340
+
341
+ col += (grainHash(gl_FragCoord.xy + vec2(t * 17.0)) - 0.5) * u_grain * 0.07;
342
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
343
+ }
344
+ `;
345
+
346
+ /**
347
+ * Riso. A slow wave through the palette, read through a coarse *quantised*
348
+ * noise — the ramp is dithered into cells of flat colour before it is looked
349
+ * up, which is the risograph and newsprint fault, not a texture laid on top.
350
+ *
351
+ * That distinction is the whole field. `u_grain` adds noise to the finished
352
+ * pixel and every field here has it; this adds noise to the *position in the
353
+ * ramp*, so the speckle lands as neighbouring bands of solid colour with a
354
+ * ragged border between them. It is the one field in the set that does not
355
+ * produce a smooth gradient, and the one that survives being printed.
356
+ *
357
+ * The cell is in device pixels and does not move. A dither that drifts shimmers,
358
+ * which is a screen effect; a dither that sits still is ink.
359
+ */
360
+ const RISO = `
361
+ void main() {
362
+ float m = min(u_resolution.x, u_resolution.y);
363
+ // Two or three bands across the frame, not six. Wide bands leave flat areas
364
+ // for the dither to *not* be in, and a speckle that is everywhere is a
365
+ // texture rather than a print.
366
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / m * 1.4;
367
+ float t = u_time;
368
+
369
+ vec2 q = p;
370
+ q.x += sin(q.y * 2.1 + t * 0.2) * 0.33;
371
+ q.y += cos(q.x * 2.7 - t * 0.17) * 0.26;
372
+ float wave = 0.5 + 0.5 * sin(q.x * 2.4 + q.y * 1.6 + fbm(q * 2.0) * 3.0);
373
+
374
+ // Three device pixels: coarse enough to see as ink, fine enough that it is
375
+ // not a mosaic. The amplitude decides how far into the neighbouring band a
376
+ // speckle can reach — past ~0.3 the ramp stops being a gradient at all.
377
+ float cell = hash21(floor(gl_FragCoord.xy / 3.0));
378
+ vec3 col = palette(clamp(wave + (cell - 0.5) * 0.22, 0.0, 1.0));
379
+
380
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
381
+ }
382
+ `;
383
+
384
+ /**
385
+ * Veil. A curtain: one fbm decides where the drapery hangs, a second is read
386
+ * *along* it, and the whole thing fades out toward the top and bottom edges of
387
+ * the frame.
388
+ *
389
+ * It is the field anchored to an edge. `aurora` is centred — a glow in the
390
+ * middle with a vignette around it — and everything in it points inward; this
391
+ * hangs from the horizontal and thins as it rises, so the hero's copy sits in
392
+ * the quiet part rather than on top of the bright part. Two fields of weather
393
+ * with two different compositions.
394
+ *
395
+ * Where the curtain is absent the ground shows through, which is why it wants a
396
+ * ground worth showing.
397
+ */
398
+ const VEIL = `
399
+ void main() {
400
+ float m = min(u_resolution.x, u_resolution.y);
401
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / m * 1.88;
402
+ float t = u_time;
403
+
404
+ float curtain = fbm(vec2(p.x * 2.0 + t * 0.15, p.y * 0.6 - t * 0.05));
405
+ // The second sample is indexed by the first rather than offset by it, which
406
+ // is what makes the bands hang vertically instead of drifting sideways.
407
+ float band = fbm(vec2(p.x * 2.2 - t * 0.1, curtain * 2.4));
408
+ // The upper bound sits past 1: the curtain never quite reaches the brightest
409
+ // colour, which is the difference between a veil and a floodlight.
410
+ float glow = smoothstep(0.18, 1.05, band) * (1.0 - abs(p.y) * 0.55);
411
+
412
+ vec3 col = mix(u_bg, palette(clamp(glow, 0.0, 1.0)),
413
+ 0.12 + 0.88 * smoothstep(0.0, 0.35, glow));
414
+
415
+ col += (grainHash(gl_FragCoord.xy + vec2(t * 17.0)) - 0.5) * u_grain * 0.08;
416
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
417
+ }
418
+ `;
419
+
420
+ export const FRAGMENT: Record<ShaderField, string> = {
421
+ drift: HEAD + VALUE_NOISE + DRIFT,
422
+ aurora: HEAD + SIMPLEX + AURORA,
423
+ plasma: HEAD + PALETTE + PLASMA,
424
+ glow: HEAD + SIMPLEX + GLOW,
425
+ flow: HEAD + VALUE_NOISE + PALETTE + FLOW,
426
+ silk: HEAD + PALETTE + SILK,
427
+ smoke: HEAD + VALUE_NOISE + PALETTE + SMOKE,
428
+ riso: HEAD + VALUE_NOISE + PALETTE + RISO,
429
+ veil: HEAD + VALUE_NOISE + PALETTE + VEIL,
430
+ };
@@ -0,0 +1,278 @@
1
+ ---
2
+ /**
3
+ * The page's ground.
4
+ *
5
+ * One decorative layer behind the content, printed in the variant's own tokens.
6
+ * It is the opposite trade from `HeroField`: no canvas, no JavaScript, nothing
7
+ * that can lose a context, and it is meant to be *quiet*. The hero is where a
8
+ * page spends its one loud moment; this is the paper the rest of it is printed
9
+ * on, and a ground somebody notices is a ground turned up too far.
10
+ *
11
+ * <Pattern pattern="rules" scope="page" />
12
+ * <Pattern pattern="construction" opacity={0.05} draw />
13
+ * <Pattern pattern="motif"><path d="…" /></Pattern>
14
+ *
15
+ * Six grounds ship and they are not six skins of one look — pick from the
16
+ * design, not by taste. What each one is for is in the table in
17
+ * `docs/surface.md` of @jtakeit/astro; the figures themselves are in
18
+ * ./patterns.ts. The seventh choice is **none**, and it is a real one: on a
19
+ * page carrying strong photographs it is usually the right answer. It is made
20
+ * by not rendering this component — `jtk/design.json` leaves
21
+ * `surface.pattern` empty and nothing appears.
22
+ *
23
+ * ── it fails to nothing ─────────────────────────────────────────────────────
24
+ *
25
+ * No JavaScript, no mask support, a printer, reduced motion: every one of them
26
+ * ends at the plain token ground, which is a design somebody chose rather than
27
+ * a hole in the page. Nothing readable is ever inside this layer, nothing is
28
+ * ever positioned against it, and it is never the LCP element.
29
+ */
30
+ import { STENCILS, DRAWINGS, DEFAULTS, isStencil, nextMotifId, type Pattern } from './patterns';
31
+
32
+ interface Props {
33
+ /**
34
+ * grain · rules · hatch · contour · construction · motif.
35
+ * Required: there is no sensible default ground, and a default is exactly how
36
+ * fifty sites end up sharing one. Choosing *none* means not rendering this.
37
+ */
38
+ pattern: Pattern;
39
+ /**
40
+ * `page` pins one layer behind the whole document; `block` fills the section
41
+ * it sits in — that section needs `class="has-pattern"`, which is the two
42
+ * properties the layer has to sit under content rather than over it.
43
+ *
44
+ * A page-scope ground is only visible through sections that let it through.
45
+ * On a stack of full-bleed bands with their own backgrounds, half of them
46
+ * cover it — use `block` there, on the sections that should carry it.
47
+ */
48
+ scope?: 'page' | 'block';
49
+ /** The figure's pitch: line spacing, hatch gap, motif tile. Any CSS length. */
50
+ size?: string;
51
+ /** Stroke weight. Any CSS length. */
52
+ weight?: string;
53
+ /**
54
+ * The volume knob, and the property most likely to be set too high. A ground
55
+ * that competes with body text makes the page harder to read for everyone who
56
+ * did not choose it, so `fl-check` measures this one rather than leaving it
57
+ * to taste.
58
+ */
59
+ opacity?: number;
60
+ /** What the ground is printed in. A token, not a colour. */
61
+ ink?: string;
62
+ /**
63
+ * Let a drawn ground set itself out once, in order, and then stop. Ignored by
64
+ * the stencils — a texture has no beginning. Reduced motion gets the finished
65
+ * drawing, never a missing one.
66
+ */
67
+ draw?: boolean;
68
+ class?: string;
69
+ }
70
+
71
+ const {
72
+ pattern,
73
+ scope = 'block',
74
+ size,
75
+ weight,
76
+ opacity,
77
+ ink,
78
+ draw = false,
79
+ class: className,
80
+ } = Astro.props;
81
+
82
+ /*
83
+ * `motif` is the ground that carries the client's own mark — their tile, their
84
+ * fabric, their stamp, the shape off their sign — and it is the one entry in
85
+ * the catalogue that cannot be filled in from a catalogue. Empty, it would
86
+ * render an invisible layer that looks like it works, so it fails the build
87
+ * instead, the way `HeroField` fails without a palette.
88
+ */
89
+ if (pattern === 'motif' && !Astro.slots.has('default')) {
90
+ throw new Error(
91
+ '<Pattern pattern="motif"> needs the mark itself as its content — one SVG figure ' +
92
+ "taken from this business's own material. See references/surface.md; the other " +
93
+ 'five grounds are chosen from the catalogue, this one is not.',
94
+ );
95
+ }
96
+
97
+ const base = DEFAULTS[pattern];
98
+ const stencil = isStencil(pattern) ? STENCILS[pattern] : null;
99
+ const drawing = pattern === 'motif' ? null : DRAWINGS[pattern as keyof typeof DRAWINGS];
100
+
101
+ /*
102
+ * The geometry lives on the layer rather than on :root, because the same token
103
+ * name means a 180px noise tile to one ground and a 14px hatch gap to another —
104
+ * one shared value would put at least one of them in the wrong place. A variant
105
+ * retunes a ground per breakpoint by targeting `.pattern`, which is also where
106
+ * it can drop the pitch on a phone.
107
+ */
108
+ const vars = [
109
+ `--pattern-size:${size ?? base.size}`,
110
+ `--pattern-weight:${weight ?? base.weight}`,
111
+ `--pattern-opacity:${opacity ?? base.opacity}`,
112
+ ink ? `--pattern-ink:${ink}` : '',
113
+ stencil ? `--pattern-stencil:${stencil}` : '',
114
+ ]
115
+ .filter(Boolean)
116
+ .join(';');
117
+
118
+ /* Its own tile, in user units: an SVG <pattern> takes a number, not a token. */
119
+ const tile = pattern === 'motif' ? Math.max(1, parseFloat(size ?? base.size) || 96) : 0;
120
+ const motifId = pattern === 'motif' ? nextMotifId() : '';
121
+ ---
122
+
123
+ <div
124
+ class:list={['pattern', `pattern--${scope}`, className]}
125
+ data-pattern={pattern}
126
+ data-draw={draw && !stencil ? '' : undefined}
127
+ style={vars}
128
+ aria-hidden="true"
129
+ >
130
+ {
131
+ stencil ? (
132
+ <div class="pattern__stencil" />
133
+ ) : pattern === 'motif' ? (
134
+ <svg class="pattern__drawing" aria-hidden="true" focusable="false">
135
+ <defs>
136
+ <pattern id={motifId} width={tile} height={tile} patternUnits="userSpaceOnUse">
137
+ <slot />
138
+ </pattern>
139
+ </defs>
140
+ <rect width="100%" height="100%" fill={`url(#${motifId})`} />
141
+ </svg>
142
+ ) : (
143
+ <svg
144
+ class="pattern__drawing"
145
+ viewBox={drawing?.viewBox}
146
+ preserveAspectRatio="xMidYMid slice"
147
+ fill="none"
148
+ stroke="currentColor"
149
+ aria-hidden="true"
150
+ focusable="false"
151
+ set:html={drawing?.markup}
152
+ />
153
+ )
154
+ }
155
+ </div>
156
+
157
+ <style>
158
+ .pattern {
159
+ pointer-events: none;
160
+ color: var(--pattern-ink, var(--ink, #000));
161
+ }
162
+
163
+ /*
164
+ * Behind the document. A fixed layer costs one composited surface and does
165
+ * not repaint on scroll, which is why the page ground is fixed rather than a
166
+ * tall repeating image: the same picture for a tenth of the work.
167
+ */
168
+ .pattern--page {
169
+ position: fixed;
170
+ inset: 0;
171
+ z-index: -1;
172
+ }
173
+
174
+ /*
175
+ * Inside its section. `z-index: -1` puts it above that section's background
176
+ * and below its content — but only where the section is a stacking context,
177
+ * which is what `.has-pattern` is for. Without it the layer disappears behind
178
+ * the band's own colour.
179
+ */
180
+ .pattern--block {
181
+ position: absolute;
182
+ inset: 0;
183
+ z-index: -1;
184
+ overflow: hidden;
185
+ }
186
+
187
+ .pattern__stencil,
188
+ .pattern__drawing {
189
+ position: absolute;
190
+ inset: 0;
191
+ inline-size: 100%;
192
+ block-size: 100%;
193
+ opacity: var(--pattern-opacity, 0.06);
194
+ }
195
+
196
+ /*
197
+ * The ink, stencilled. Painting a token through a mask rather than shipping a
198
+ * coloured image is what keeps the ground in the variant's palette — and it
199
+ * follows the studio bar's live accent for the same reason `HeroField` does.
200
+ *
201
+ * Where masks are unsupported the layer would be a solid block of ink over
202
+ * the page, so it is not drawn at all: no ground is a design, a rectangle of
203
+ * ink is a bug.
204
+ */
205
+ .pattern__stencil {
206
+ display: none;
207
+ }
208
+
209
+ @supports (mask-image: linear-gradient(#000, #000)) or
210
+ (-webkit-mask-image: linear-gradient(#000, #000)) {
211
+ .pattern__stencil {
212
+ display: block;
213
+ background-color: currentColor;
214
+ -webkit-mask-image: var(--pattern-stencil);
215
+ mask-image: var(--pattern-stencil);
216
+ -webkit-mask-repeat: repeat;
217
+ mask-repeat: repeat;
218
+ }
219
+ }
220
+
221
+ /*
222
+ * Only a tile has a size. The ruled and hatched grounds are gradients, which
223
+ * measure their pitch themselves out of --pattern-size and repeat inside one
224
+ * mask box; sizing that box would squash the figure instead of tiling it.
225
+ */
226
+ .pattern[data-pattern='grain'] .pattern__stencil {
227
+ -webkit-mask-size: var(--pattern-size) var(--pattern-size);
228
+ mask-size: var(--pattern-size) var(--pattern-size);
229
+ }
230
+
231
+ .pattern__drawing {
232
+ stroke-width: var(--pattern-weight, 1px);
233
+ }
234
+
235
+ /* The figures arrive as markup from patterns.ts, so the scoped selector does
236
+ not reach them. */
237
+ .pattern__drawing :global(*) {
238
+ vector-effect: non-scaling-stroke;
239
+ }
240
+
241
+ /*
242
+ * Setting out, once. The still state is the default, so a visitor with no
243
+ * JavaScript sees the finished drawing — `html.js` is set before first paint
244
+ * in the layout, and only then is anything hidden.
245
+ *
246
+ * `pathLength="1"` on every figure is what makes this one rule work for a
247
+ * circle, a line and a 56-point contour alike: the browser measures the path,
248
+ * nobody types a length into the markup, and a figure edited later still
249
+ * draws correctly.
250
+ */
251
+ html.js .pattern[data-draw] .pattern__drawing :global(*) {
252
+ stroke-dasharray: 1;
253
+ stroke-dashoffset: 1;
254
+ animation: pattern-draw 1.4s var(--ease, ease) forwards;
255
+ animation-delay: calc(var(--i, 0) * 180ms);
256
+ }
257
+
258
+ @keyframes pattern-draw {
259
+ to {
260
+ stroke-dashoffset: 0;
261
+ }
262
+ }
263
+
264
+ /* Reduced motion means the finished picture, not a missing one. */
265
+ @media (prefers-reduced-motion: reduce) {
266
+ html.js .pattern[data-draw] .pattern__drawing :global(*) {
267
+ stroke-dashoffset: 0;
268
+ animation: none;
269
+ }
270
+ }
271
+
272
+ /* Ink is expensive and a texture is not the content. */
273
+ @media print {
274
+ .pattern {
275
+ display: none;
276
+ }
277
+ }
278
+ </style>