@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,472 @@
1
+ ---
2
+ /**
3
+ * One photograph.
4
+ *
5
+ * A missing file must not take the layout with it: the frame keeps its aspect
6
+ * ratio and its plate from CSS alone, so a failed lookup costs the picture and
7
+ * nothing else. That is what lets a photo be swapped — or arrive late — without
8
+ * touching any page.
9
+ *
10
+ * The other rule this component exists to enforce: **a photograph is never
11
+ * stretched.** A frame narrower or taller than the picture crops into it; it
12
+ * never squeezes it. Distortion is the one image fault a client always sees and
13
+ * never has words for — "it looks weird" — so it is not left to per-variant
14
+ * CSS. `object-fit` is set here, in every branch, and a frame that would throw
15
+ * away most of the picture says so during the build.
16
+ *
17
+ * ── the treatment ───────────────────────────────────────────────────────────
18
+ *
19
+ * The second thing this component owns is what a photograph is *printed* like.
20
+ * Client photos arrive as a feed — twenty pictures in twenty lights, three
21
+ * phones, two years, white balance disagreeing between every pair — and cropped
22
+ * well they still disagree. A treatment is what makes them one set: a couple of
23
+ * blended layers over the picture, in the variant's own tokens, with no build
24
+ * step and no second copy of any file.
25
+ *
26
+ * Six of them, in references/photos.md, and two rules: one per site, and never
27
+ * a strong one on their face. The second is enforced below rather than advised.
28
+ */
29
+ import { Image } from 'astro:assets';
30
+ import { GRAIN_TILE, GRAIN_SIZE_PHOTO } from '../lib/noise';
31
+ import { STRONG, altFor, isPerson, photo, shape, type Treatment } from '../lib/photos';
32
+ import design from '../../jtk/design.json';
33
+
34
+ interface Props {
35
+ /** File base name in src/assets, without the extension. */
36
+ name: string;
37
+ /** Overrides the alt text registered in photos.ts. */
38
+ alt?: string;
39
+ /**
40
+ * CSS aspect-ratio for the frame, e.g. '4 / 5'. Omit it to let the photograph
41
+ * keep its own proportions — client photos are shot in every orientation and
42
+ * a square crop takes the subject out of half of them.
43
+ */
44
+ ratio?: string;
45
+ /**
46
+ * How the picture meets a frame it does not match. `cover` crops, `contain`
47
+ * fits the whole picture inside and leaves the plate visible around it. There
48
+ * is deliberately no third option: `fill` distorts.
49
+ */
50
+ fit?: 'cover' | 'contain';
51
+ /**
52
+ * What the crop keeps when it cannot keep everything — any `object-position`
53
+ * value. The default centres, which is wrong for nearly every portrait: a
54
+ * face sits in the top third, and a wide hero frame crops it off. `50% 25%`
55
+ * is the usual fix.
56
+ */
57
+ focus?: string;
58
+ /** Rendered width hint for the srcset. */
59
+ width?: number;
60
+ /** The first contentful image on the page. One per page, never two. */
61
+ eager?: boolean;
62
+ /** Describes the layout, not the file. A wrong value is why a phone still
63
+ downloads the 2400px variant. */
64
+ sizes?: string;
65
+ caption?: string;
66
+ /**
67
+ * `data-jtk-path` for this picture, where it comes from the content document.
68
+ *
69
+ * It goes on the `<img>` and on the placeholder that stands in for a missing
70
+ * file — **never on the figure around them**. The admin replaces a picture by
71
+ * setting `src` on the element it was told about, and an element that is not
72
+ * an image gets its text replaced instead: annotating the figure turns the
73
+ * client's own photograph into a URL printed where the picture was.
74
+ */
75
+ path?: string;
76
+ /**
77
+ * This picture says nothing the page does not already say.
78
+ *
79
+ * A pile of four photographs is one thing to look at and four alt texts read
80
+ * out in a row, which is worse for a screen reader than one. So the frame
81
+ * that carries the meaning keeps its alt and the rest are marked here: empty
82
+ * alt, which is how a decorative image is skipped rather than described
83
+ * badly. It is a composition decision, so it is per use, not per file.
84
+ */
85
+ decorative?: boolean;
86
+ /**
87
+ * How the picture is printed: none · grade · duotone · film · press · recede.
88
+ *
89
+ * Defaults to whatever `jtk/design.json` chose for the site, which is
90
+ * the answer nearly always — a treatment is a property of the page, not of
91
+ * one picture, and a page where two photographs are printed differently reads
92
+ * as a page where nobody decided. Set it per photo only to take one *out* of
93
+ * the treatment, or to push one photograph back under copy with `recede`.
94
+ */
95
+ treatment?: Treatment;
96
+ /** 0–1, how far the treatment goes. Below ~0.4 nobody can tell it is on. */
97
+ amount?: number;
98
+ class?: string;
99
+ }
100
+
101
+ const {
102
+ name,
103
+ alt,
104
+ ratio,
105
+ fit = 'cover',
106
+ focus = '50% 50%',
107
+ width = 900,
108
+ eager = false,
109
+ /* Why the <Image> below is given layout="none".
110
+ This component picks its own widths and its own sizes, so the project's
111
+ default layout has nothing to add and would only put its own attributes on
112
+ top of them. That default exists for markdown bodies, which have no props
113
+ to pick with — see astro.config.mjs.
114
+
115
+ The note lives here rather than beside the attribute it explains, because
116
+ a JSX-style comment between two attributes is not valid Astro. It parses
117
+ as the start of an expression, and the five errors it produced began with
118
+ "unterminated string literal" a hundred lines further down. */
119
+ sizes = '(min-width: 900px) 50vw, 92vw',
120
+ caption,
121
+ path,
122
+ decorative = false,
123
+ treatment = (design.photos?.treatment || 'none') as Treatment,
124
+ /* 0 in the document means unchosen, the same as everywhere else in it — a
125
+ treatment that is genuinely off is `treatment: "none"`, not amount zero. */
126
+ amount = design.photos?.amount || undefined,
127
+ class: className,
128
+ } = Astro.props;
129
+
130
+ const src = photo(name);
131
+ const text = decorative ? '' : (alt ?? altFor(name));
132
+
133
+ /*
134
+ * A face is not a surface to decorate.
135
+ *
136
+ * `duotone` and `press` replace skin colour, and a one-person business is
137
+ * chosen by that face — turned two-tone it reads as stock art, which is the
138
+ * opposite of what the photograph is there to say. photos.md has the long
139
+ * version; this is the part that cannot be forgotten under time pressure,
140
+ * because it stops the build instead of looking fine in the window it was
141
+ * written in.
142
+ *
143
+ * The escape is explicit and per photo: pass `treatment="grade"`, or do not
144
+ * register the slot as a person if it genuinely is not their face.
145
+ */
146
+ if (STRONG.includes(treatment) && isPerson(name)) {
147
+ throw new Error(
148
+ `<Shot name="${name}" treatment="${treatment}"> — that slot is registered as the person ` +
149
+ 'this business is, and this treatment replaces skin colour. Their face is what a ' +
150
+ 'customer is choosing: leave it alone, or pass treatment="grade". See photos.md.',
151
+ );
152
+ }
153
+
154
+ const treated = treatment !== 'none';
155
+ const tone = [
156
+ amount !== undefined ? `--photo-amount:${amount}` : '',
157
+ treatment === 'film' ? `--photo-grain:${GRAIN_TILE};--photo-grain-size:${GRAIN_SIZE_PHOTO}` : '',
158
+ ]
159
+ .filter(Boolean)
160
+ .join(';');
161
+
162
+ /**
163
+ * Measure before placing. A frame is a decision about which part of the
164
+ * photograph the visitor never sees, and taken by feel it is usually taken
165
+ * wrong — a 4:5 portrait dropped into a 16:9 band loses 55% of itself, most of
166
+ * it the half with the subject in it.
167
+ *
168
+ * This does not fail the build: some crops are deliberate. It puts the number
169
+ * in the build log in front of whoever chose the frame, at the moment they can
170
+ * still change it.
171
+ */
172
+ const asked = ratio ? Number(ratio.split('/')[0]) / Number(ratio.split('/')[1] ?? 1) : null;
173
+ const own = shape(name);
174
+ if (asked && own && fit === 'cover') {
175
+ const kept = Math.min(asked, own.ratio) / Math.max(asked, own.ratio);
176
+ if (kept < 0.6) {
177
+ console.warn(
178
+ `[shot] ${name} is ${own.width}×${own.height} (${own.orientation}) and the frame asks for ` +
179
+ `${ratio} — the crop throws away ${Math.round((1 - kept) * 100)}% of it.`,
180
+ );
181
+ }
182
+ }
183
+
184
+ const frame = ratio ? `aspect-ratio:${ratio}` : undefined;
185
+ const placement = `--shot-fit:${fit};--shot-focus:${focus}`;
186
+ ---
187
+
188
+ {
189
+ src ? (
190
+ <figure class:list={['shot', !ratio && 'shot--natural', className]}>
191
+ <div
192
+ class="shot__frame"
193
+ data-treatment={treated ? treatment : undefined}
194
+ style={[frame, placement, tone].filter(Boolean).join(';')}
195
+ >
196
+ <Image
197
+ src={src}
198
+ alt={text}
199
+ data-jtk-path={path}
200
+ widths={[Math.round(width / 2), width, width * 2]}
201
+ sizes={sizes}
202
+ layout="none"
203
+ loading={eager ? 'eager' : 'lazy'}
204
+ decoding={eager ? 'sync' : 'async'}
205
+ fetchpriority={eager ? 'high' : undefined}
206
+ />
207
+ {treated && (
208
+ <>
209
+ <span class="shot__tone shot__tone--a" aria-hidden="true" />
210
+ <span class="shot__tone shot__tone--b" aria-hidden="true" />
211
+ </>
212
+ )}
213
+ </div>
214
+ {caption && <figcaption class="shot__caption">{caption}</figcaption>}
215
+ </figure>
216
+ ) : (
217
+ <div class:list={['shot', 'shot--placeholder', className]} style={frame ?? 'aspect-ratio:4 / 3'}>
218
+ {name}
219
+ {/*
220
+ The frame is empty and it is still the thing to tap.
221
+ A client who adds a photograph in the admin has to see it land, and what
222
+ the admin patches is an image's `src` — so there is an image here, one
223
+ transparent pixel of it, waiting. Without it the annotation would have to
224
+ go on the box, and patching a box replaces its text.
225
+ */}
226
+ {path && (
227
+ <img
228
+ class="shot__ghost"
229
+ data-jtk-path={path}
230
+ alt=""
231
+ src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
232
+ />
233
+ )}
234
+ </div>
235
+ )
236
+ }
237
+
238
+ <style>
239
+ .shot {
240
+ margin: 0;
241
+ }
242
+
243
+ /*
244
+ * The frame, and why it is not the <figure>.
245
+ *
246
+ * The crop, the radius and the treatment's layers all belong to the picture's
247
+ * box; a caption sits under it and must be in none of them. With the ratio on
248
+ * the figure, a captioned <Shot> also clipped its own caption — the figure was
249
+ * exactly the picture's height and `overflow: hidden` did the rest.
250
+ */
251
+ .shot__frame {
252
+ position: relative;
253
+ isolation: isolate;
254
+ overflow: hidden;
255
+ border-radius: var(--r-2, 8px);
256
+ }
257
+
258
+ /*
259
+ * The picture fills the frame and is cropped by it. `object-fit` is not
260
+ * optional here and there is no path through this component that leaves it
261
+ * unset: an <img> given both dimensions without it is stretched, which is the
262
+ * fault this component exists to make impossible.
263
+ */
264
+ .shot :global(img) {
265
+ display: block;
266
+ inline-size: 100%;
267
+ block-size: 100%;
268
+ object-fit: var(--shot-fit, cover);
269
+ object-position: var(--shot-focus, 50% 50%);
270
+ }
271
+
272
+ /* Without a declared ratio the picture sets the height and object-fit has
273
+ nothing to crop — the frame is only there for the radius and the caption. */
274
+ .shot--natural :global(img) {
275
+ block-size: auto;
276
+ }
277
+
278
+ /* The waiting image: over the label, filling the frame, invisible until it
279
+ is given a picture. */
280
+ .shot__ghost {
281
+ position: absolute;
282
+ inset: 0;
283
+ inline-size: 100%;
284
+ block-size: 100%;
285
+ object-fit: cover;
286
+ }
287
+
288
+ /* Deliberately visible and labelled: a gap you can see is a gap somebody
289
+ fills, and a gap that looks like a bug is a gap that ships. */
290
+ .shot--placeholder {
291
+ position: relative;
292
+ overflow: hidden;
293
+ border-radius: var(--r-2, 8px);
294
+ display: grid;
295
+ place-items: center;
296
+ background: var(--paper-deep, #eceae5);
297
+ color: var(--ink-quiet, #8a857d);
298
+ font: 500 0.75rem/1 system-ui, sans-serif;
299
+ letter-spacing: 0.08em;
300
+ text-transform: uppercase;
301
+ }
302
+
303
+ /* ── treatments ────────────────────────────────────────────────────────────
304
+ *
305
+ * Two layers over the picture and a filter on it. Nothing here names a
306
+ * colour: the ink comes from --photo-shadow / --photo-light / --photo-ink,
307
+ * which are tokens, so a treatment follows the variant's palette and the
308
+ * studio bar's live accent the same way a ground does.
309
+ *
310
+ * --photo-amount is the volume knob and multiplies every layer. At 0 the
311
+ * photograph is untouched, which is what makes it safe to turn down rather
312
+ * than off when a client says "less".
313
+ *
314
+ * Where blend modes do not land, what is left is the photograph. That is the
315
+ * whole failure path.
316
+ */
317
+ .shot__tone {
318
+ position: absolute;
319
+ inset: 0;
320
+ pointer-events: none;
321
+ }
322
+
323
+ /*
324
+ * grade — the page's own light. Colour is kept; the shadows are pulled toward
325
+ * the ground's hue and the highlights toward the paper, which is what stops
326
+ * eight photographs shot in eight kitchens from disagreeing. The gentle one,
327
+ * and the only one that is safe on a face.
328
+ */
329
+ .shot__frame[data-treatment='grade'] :global(img) {
330
+ filter: saturate(0.94) contrast(1.05);
331
+ }
332
+
333
+ .shot__frame[data-treatment='grade'] .shot__tone--a {
334
+ background: var(--photo-shadow);
335
+ mix-blend-mode: multiply;
336
+ opacity: calc(0.22 * var(--photo-amount, 1));
337
+ }
338
+
339
+ .shot__frame[data-treatment='grade'] .shot__tone--b {
340
+ background: var(--photo-light);
341
+ mix-blend-mode: screen;
342
+ opacity: calc(0.12 * var(--photo-amount, 1));
343
+ }
344
+
345
+ /*
346
+ * duotone — the colour is thrown away and the tonal range is remapped onto two
347
+ * tokens: `lighten` raises the blacks to the shadow colour, `multiply` pulls
348
+ * the whites down to the light one. The strongest unifier here, because a
349
+ * photograph's original colour is exactly what was disagreeing.
350
+ *
351
+ * Never on their face. <Shot> fails the build rather than let it happen.
352
+ */
353
+ .shot__frame[data-treatment='duotone'] :global(img) {
354
+ filter: grayscale(1) contrast(1.12) brightness(1.02);
355
+ }
356
+
357
+ .shot__frame[data-treatment='duotone'] .shot__tone--a {
358
+ background: var(--photo-shadow);
359
+ mix-blend-mode: lighten;
360
+ opacity: calc(0.92 * var(--photo-amount, 1));
361
+ }
362
+
363
+ .shot__frame[data-treatment='duotone'] .shot__tone--b {
364
+ background: var(--photo-light);
365
+ mix-blend-mode: multiply;
366
+ opacity: calc(0.92 * var(--photo-amount, 1));
367
+ }
368
+
369
+ /*
370
+ * film — lifted blacks, a milky highlight and grain. It is what makes a phone
371
+ * photograph look like it was taken on purpose.
372
+ *
373
+ * True halation — the bright-pass blurred back over the picture — is
374
+ * deliberately not here: it costs a second copy of every image and a
375
+ * full-width blur to composite, and on a page of eight photographs that is
376
+ * paid eight times for the last tenth of the effect.
377
+ */
378
+ .shot__frame[data-treatment='film'] :global(img) {
379
+ filter: saturate(0.88) contrast(0.94) brightness(1.05);
380
+ }
381
+
382
+ .shot__frame[data-treatment='film'] .shot__tone--a {
383
+ background: var(--photo-light);
384
+ mix-blend-mode: screen;
385
+ opacity: calc(0.12 * var(--photo-amount, 1));
386
+ }
387
+
388
+ /* Without masks the grain layer would be a flat wash of ink over the
389
+ photograph, so it is not drawn at all. */
390
+ .shot__frame[data-treatment='film'] .shot__tone--b {
391
+ display: none;
392
+ }
393
+
394
+ @supports (mask-image: linear-gradient(#000, #000)) or
395
+ (-webkit-mask-image: linear-gradient(#000, #000)) {
396
+ .shot__frame[data-treatment='film'] .shot__tone--b {
397
+ display: block;
398
+ background-color: var(--photo-shadow);
399
+ -webkit-mask-image: var(--photo-grain);
400
+ mask-image: var(--photo-grain);
401
+ -webkit-mask-size: var(--photo-grain-size, 90px) var(--photo-grain-size, 90px);
402
+ mask-size: var(--photo-grain-size, 90px) var(--photo-grain-size, 90px);
403
+ mix-blend-mode: overlay;
404
+ opacity: calc(0.55 * var(--photo-amount, 1));
405
+ }
406
+ }
407
+
408
+ /*
409
+ * press — screen-printed: one ink, hard contrast, a dot lattice over it. The
410
+ * photograph stops pretending to be a photograph, which is the point on a page
411
+ * whose ground is already `riso` or `hatch`.
412
+ *
413
+ * Not a real halftone — the dots are one size rather than sized by the tone
414
+ * under them, which would need a per-pixel pass. What it borrows is the
415
+ * register, and at arm's length that is the whole tell.
416
+ */
417
+ .shot__frame[data-treatment='press'] :global(img) {
418
+ filter: grayscale(1) contrast(1.45) brightness(1.06);
419
+ }
420
+
421
+ .shot__frame[data-treatment='press'] .shot__tone--a {
422
+ background-image: radial-gradient(var(--photo-shadow) 40%, transparent 44%);
423
+ background-size: var(--photo-dot, 4px) var(--photo-dot, 4px);
424
+ mix-blend-mode: multiply;
425
+ opacity: calc(0.5 * var(--photo-amount, 1));
426
+ }
427
+
428
+ .shot__frame[data-treatment='press'] .shot__tone--b {
429
+ background: var(--photo-ink);
430
+ mix-blend-mode: multiply;
431
+ opacity: calc(0.35 * var(--photo-amount, 1));
432
+ }
433
+
434
+ /*
435
+ * recede — the photograph pushed back into the page so that copy can sit on
436
+ * it. It is the band-sized answer to the same problem `scrim` solves for the
437
+ * hero's canvas: contrast that holds over the light half of a picture does
438
+ * not hold over the dark half.
439
+ *
440
+ * A plain veil, not a blend: it has to work over a photograph whose tones are
441
+ * unknown, and every blend mode has a picture that defeats it.
442
+ */
443
+ .shot__frame[data-treatment='recede'] :global(img) {
444
+ filter: saturate(0.7) contrast(0.92);
445
+ }
446
+
447
+ .shot__frame[data-treatment='recede'] .shot__tone--a {
448
+ background: var(--photo-veil);
449
+ opacity: calc(0.55 * var(--photo-amount, 1));
450
+ }
451
+
452
+ .shot__frame[data-treatment='recede'] .shot__tone--b {
453
+ display: none;
454
+ }
455
+
456
+ /* Ink is expensive and a treatment is not the picture. */
457
+ @media print {
458
+ .shot__tone {
459
+ display: none;
460
+ }
461
+
462
+ .shot__frame :global(img) {
463
+ filter: none;
464
+ }
465
+ }
466
+
467
+ .shot__caption {
468
+ padding-block-start: 0.5rem;
469
+ font-size: 0.8125rem;
470
+ color: var(--ink-quiet, #6f6a63);
471
+ }
472
+ </style>