@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,66 @@
1
+ ---
2
+ /**
3
+ * The first screen's words.
4
+ *
5
+ * Its own component because it is rendered in two places — inside `HeroField`
6
+ * when a palette has been chosen, and in a plain section when one has not — and
7
+ * duplicating it would mean one of the two losing an annotation the day
8
+ * somebody edits the other. Astro's frontmatter is TypeScript rather than JSX,
9
+ * so a local variable holding the markup is not an option; a component is.
10
+ */
11
+ import { block, flPath, picture, HOME } from '../copy/{{LOCALE}}';
12
+ import Shot from './Shot.astro';
13
+
14
+ /*
15
+ * The hero's photograph, where the document has one.
16
+ *
17
+ * It is a field rather than a file written into this component for the same
18
+ * reason the heading is: the owner can change it. `picture()` gives the slot,
19
+ * its words and its annotation together — and the annotation is what makes the
20
+ * picture tappable, which is the whole of "every photograph on the page is
21
+ * either a gallery item or a field of its own".
22
+ *
23
+ * Rendered after the words and in its own element, so a design pass can put it
24
+ * anywhere — beside them, behind them, or nowhere — without touching what it is.
25
+ */
26
+ const hero = block('hero');
27
+ const shot = picture(hero.of, hero.at, 'image');
28
+ ---
29
+
30
+ <div class="hero__inner">
31
+ {HOME.hero.eyebrow && (
32
+ <p class="eyebrow" data-jtk-path={flPath('hero', 'eyebrow')}>{HOME.hero.eyebrow}</p>
33
+ )}
34
+ {/* Guarded like everything else around it, and it is the one that was not.
35
+ `title` is `required: true` in the catalogue, so a real page always has
36
+ its h1; the only document where this is empty is the untouched scaffold,
37
+ where an empty <h1> is worth nothing to a reader and made `fl-catalogue`
38
+ fail on a project nobody had edited yet. */}
39
+ {HOME.hero.heading && (
40
+ <h1 data-jtk-path={flPath('hero', 'title')}>{HOME.hero.heading}</h1>
41
+ )}
42
+ {HOME.hero.lead && (
43
+ <p class="lead" data-jtk-path={flPath('hero', 'lead')}>{HOME.hero.lead}</p>
44
+ )}
45
+ {HOME.hero.cta && (
46
+ <p class="actions">
47
+ <a class="btn" href={HOME.hero.ctaHref} data-jtk-path={flPath('hero', 'cta_label')}>
48
+ {HOME.hero.cta}
49
+ </a>
50
+ </p>
51
+ )}
52
+
53
+ {/* The page's largest picture, so it is the one loaded eagerly — one per
54
+ page, never two. */}
55
+ {shot.name && (
56
+ <Shot
57
+ name={shot.name}
58
+ alt={shot.alt}
59
+ path={shot.path}
60
+ class="hero__shot"
61
+ eager
62
+ width={1200}
63
+ sizes="(min-width: 62rem) 50vw, 92vw"
64
+ />
65
+ )}
66
+ </div>
@@ -0,0 +1,347 @@
1
+ ---
2
+ /**
3
+ * The lead form.
4
+ *
5
+ * It is a real `<form method="post">` first and an in-place submission second:
6
+ * with JavaScript off the browser posts to the same endpoint and the function
7
+ * answers with a plain page. The script only spares a working browser the
8
+ * navigation.
9
+ *
10
+ * `novalidate` because the messages are ours, in the client's language, rather
11
+ * than the browser's in the browser's. Every rule enforced here is enforced
12
+ * again by the platform behind /api/lead — the endpoint is public and the browser's
13
+ * checks are a convenience, not a gate.
14
+ */
15
+ import { FORM, flPath } from '../copy/{{LOCALE}}';
16
+ import { under } from '../lib/under';
17
+ import { BLOCKS, LOCALE, words, type Field } from '../content/blocks';
18
+
19
+ /**
20
+ * The site's own questions, from the catalogue (`asks` on the block that
21
+ * collects). Declared once in blocks.ts and drawn here, so the control on
22
+ * the page and the label in the owner's inbox are the same declaration —
23
+ * see the comment on `asks` in blocks.ts for what the platform holds the
24
+ * form to.
25
+ */
26
+ const asks: Field[] = BLOCKS.find((block) => block.type === 'cta_banner')?.asks ?? [];
27
+
28
+ /** The `<input type>` a visitor types this kind into. */
29
+ function inputType(kind: Field['kind']): string {
30
+ switch (kind) {
31
+ case 'number': return 'number';
32
+ case 'tel': return 'tel';
33
+ case 'email': return 'email';
34
+ case 'url': return 'url';
35
+ case 'date': return 'date';
36
+ case 'time_of_day': return 'time';
37
+ default: return 'text';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * ── every annotated element is guarded, and this file forgot ────────────────
43
+ *
44
+ * `Blocks.astro` wraps each `data-jtk-path` element in a test that its value is
45
+ * not empty, and where a block carries the form it hands `title`, `lead` and
46
+ * `cta_label` to this component instead — see `!carriesForm` there. The
47
+ * responsibility moved and the guard did not, so this file annotated seven
48
+ * elements whose values were empty.
49
+ *
50
+ * That is what `fl-catalogue` calls "on the page and not in the catalogue": it
51
+ * excuses a field the document leaves empty, on the ground that an empty field
52
+ * renders nothing. Here it rendered an empty element with a tap target on it,
53
+ * and a freshly scaffolded project therefore failed its own check before
54
+ * anybody had written a word.
55
+ */
56
+
57
+ ---
58
+
59
+ <form
60
+ class="lead"
61
+ method="post"
62
+ action={under('/api/lead')}
63
+ novalidate
64
+ data-lead-form
65
+ data-success={FORM.success}
66
+ data-error={FORM.error}
67
+ data-sending={FORM.sending}
68
+ >
69
+ <div class="lead__head">
70
+ {FORM.title && (
71
+ <h2 class="lead__title" data-jtk-path={flPath('cta_banner', 'title')}>{FORM.title}</h2>
72
+ )}
73
+ {FORM.lead && (
74
+ <p class="lead__lead" data-jtk-path={flPath('cta_banner', 'lead')}>{FORM.lead}</p>
75
+ )}
76
+ </div>
77
+
78
+ <div class="field">
79
+ <label class="field__label" for="lead-name">
80
+ {FORM.name && <span data-jtk-path={flPath('cta_banner', 'name_label')}>{FORM.name}</span>}{' '}
81
+ <span aria-hidden="true">*</span>
82
+ </label>
83
+ <input
84
+ class="field__input"
85
+ id="lead-name"
86
+ name="name"
87
+ type="text"
88
+ autocomplete="name"
89
+ required
90
+ maxlength="120"
91
+ data-message={FORM.invalidName}
92
+ />
93
+ <p class="field__error" data-error-for="name" hidden></p>
94
+ </div>
95
+
96
+ <div class="field">
97
+ <label class="field__label" for="lead-contact">
98
+ {FORM.contact && (
99
+ <span data-jtk-path={flPath('cta_banner', 'contact_label')}>{FORM.contact}</span>
100
+ )}{' '}
101
+ <span aria-hidden="true">*</span>
102
+ </label>
103
+ <input
104
+ class="field__input"
105
+ id="lead-contact"
106
+ name="contact"
107
+ type="text"
108
+ inputmode="tel"
109
+ autocomplete="tel"
110
+ required
111
+ maxlength="120"
112
+ data-message={FORM.invalidContact}
113
+ />
114
+ <p class="field__error" data-error-for="contact" hidden></p>
115
+ </div>
116
+
117
+ <div class="field">
118
+ <label class="field__label" for="lead-message">
119
+ {FORM.message && (
120
+ <span data-jtk-path={flPath('cta_banner', 'message_label')}>{FORM.message}</span>
121
+ )}
122
+ </label>
123
+ <textarea
124
+ class="field__input field__input--area"
125
+ id="lead-message"
126
+ name="message"
127
+ rows="4"
128
+ maxlength="2000"></textarea>
129
+ </div>
130
+
131
+ {asks.map((ask) => (
132
+ <div class="field">
133
+ {/* The words are the catalogue's, not the document's: fixed, so the
134
+ check knows they belong to the declaration and not to nobody. */}
135
+ <label class="field__label" for={`lead-${ask.key}`} data-jtk-fixed>
136
+ {words(ask.label, LOCALE)}
137
+ {ask.required && (
138
+ <>
139
+ {' '}
140
+ <span aria-hidden="true">*</span>
141
+ </>
142
+ )}
143
+ </label>
144
+ {ask.kind === 'textarea' ? (
145
+ <textarea
146
+ class="field__input field__input--area"
147
+ id={`lead-${ask.key}`}
148
+ name={ask.key}
149
+ rows="3"
150
+ required={ask.required}
151
+ maxlength={ask.max}></textarea>
152
+ ) : ask.kind === 'select' ? (
153
+ <select class="field__input" id={`lead-${ask.key}`} name={ask.key} required={ask.required}>
154
+ <option value=""></option>
155
+ {(ask.options ?? []).map((option) => (
156
+ <option value={option} data-jtk-fixed>{words(ask.option_labels?.[option], LOCALE) || option}</option>
157
+ ))}
158
+ </select>
159
+ ) : ask.kind === 'bool' ? (
160
+ <input class="field__check" id={`lead-${ask.key}`} name={ask.key} type="checkbox" value="yes" />
161
+ ) : (
162
+ <input
163
+ class="field__input"
164
+ id={`lead-${ask.key}`}
165
+ name={ask.key}
166
+ type={inputType(ask.kind)}
167
+ required={ask.required}
168
+ maxlength={ask.max}
169
+ min={ask.kind === 'number' ? ask.min : undefined}
170
+ max={ask.kind === 'number' ? ask.max : undefined}
171
+ />
172
+ )}
173
+ </div>
174
+ ))}
175
+
176
+ {/*
177
+ Bots fill everything; people never see this. Kept out of the tab order and
178
+ out of the accessibility tree rather than hidden with `display: none`, which
179
+ some bots detect.
180
+ */}
181
+ <div class="trap" aria-hidden="true" data-jtk-fixed>
182
+ <label for="lead-website">Website</label>
183
+ <input id="lead-website" name="website" type="text" tabindex="-1" autocomplete="off" />
184
+ </div>
185
+
186
+ {FORM.requiredNote && (
187
+ <p class="lead__legal" data-jtk-path={flPath('cta_banner', 'required_note')}>
188
+ {FORM.requiredNote}
189
+ </p>
190
+ )}
191
+
192
+ <div class="lead__foot">
193
+ <button class="btn" type="submit" data-submit>
194
+ {FORM.submit && (
195
+ <span data-submit-label data-jtk-path={flPath('cta_banner', 'cta_label')}>{FORM.submit}</span>
196
+ )}
197
+ </button>
198
+ <p class="lead__status" role="status" data-status></p>
199
+ </div>
200
+ </form>
201
+
202
+ <style>
203
+ .lead {
204
+ display: grid;
205
+ gap: 1.25rem;
206
+ max-inline-size: 34rem;
207
+ }
208
+
209
+ .field {
210
+ display: grid;
211
+ gap: 0.375rem;
212
+ }
213
+
214
+ .field__label {
215
+ font-size: 0.875rem;
216
+ }
217
+
218
+ .field__input {
219
+ inline-size: 100%;
220
+ padding: 0.75rem 0.875rem;
221
+ font: inherit;
222
+ color: inherit;
223
+ background: var(--paper-deep, #f2efea);
224
+ border: 1px solid var(--line, #d9d4cc);
225
+ border-radius: var(--r-2, 8px);
226
+ }
227
+
228
+ .field__input--area {
229
+ resize: vertical;
230
+ }
231
+
232
+ .field__error {
233
+ margin: 0;
234
+ font-size: 0.8125rem;
235
+ color: var(--danger, #b3261e);
236
+ }
237
+
238
+ /* Off-screen rather than display:none — see the comment on the honeypot. */
239
+ .trap {
240
+ position: absolute;
241
+ inline-size: 1px;
242
+ block-size: 1px;
243
+ overflow: hidden;
244
+ clip-path: inset(50%);
245
+ white-space: nowrap;
246
+ }
247
+
248
+ .lead__foot {
249
+ display: flex;
250
+ flex-wrap: wrap;
251
+ align-items: center;
252
+ gap: 1rem;
253
+ }
254
+
255
+ .lead__status {
256
+ margin: 0;
257
+ font-size: 0.875rem;
258
+ }
259
+
260
+ .lead[data-state='error'] .lead__status {
261
+ color: var(--danger, #b3261e);
262
+ }
263
+ </style>
264
+
265
+ <script>
266
+ // Progressive enhancement only. Every branch here has a working no-JS
267
+ // equivalent: without this file the browser posts the form and the function
268
+ // answers with a page.
269
+ const form = document.querySelector<HTMLFormElement>('[data-lead-form]');
270
+
271
+ if (form) {
272
+ const status = form.querySelector<HTMLElement>('[data-status]')!;
273
+ const button = form.querySelector<HTMLButtonElement>('[data-submit]')!;
274
+ const label = form.querySelector<HTMLElement>('[data-submit-label]')!;
275
+ const original = label.textContent ?? '';
276
+
277
+ const showError = (field: string, message: string) => {
278
+ const el = form.querySelector<HTMLElement>(`[data-error-for="${field}"]`);
279
+ if (!el) return;
280
+ el.textContent = message;
281
+ el.hidden = !message;
282
+ };
283
+
284
+ const clearErrors = () => {
285
+ form.querySelectorAll<HTMLElement>('[data-error-for]').forEach((el) => {
286
+ el.textContent = '';
287
+ el.hidden = true;
288
+ });
289
+ };
290
+
291
+ form.addEventListener('submit', async (event) => {
292
+ event.preventDefault();
293
+ clearErrors();
294
+ status.textContent = '';
295
+ form.removeAttribute('data-state');
296
+
297
+ const data = new FormData(form);
298
+ const required = ['name', 'contact'] as const;
299
+ let ok = true;
300
+
301
+ for (const field of required) {
302
+ const input = form.elements.namedItem(field) as HTMLInputElement | null;
303
+ if (!input?.value.trim()) {
304
+ showError(field, input?.dataset.message ?? '');
305
+ ok = false;
306
+ }
307
+ }
308
+ if (!ok) return;
309
+
310
+ button.disabled = true;
311
+ label.textContent = form.dataset.sending ?? original;
312
+
313
+ try {
314
+ const response = await fetch(form.action, {
315
+ method: 'POST',
316
+ headers: { accept: 'application/json' },
317
+ body: data,
318
+ });
319
+ const body = (await response.json()) as { ok?: boolean; error?: string; message?: string; demo?: boolean };
320
+
321
+ if (response.ok && body.ok) {
322
+ form.reset();
323
+ status.textContent = form.dataset.success ?? '';
324
+ // Preview only: the bot is not connected, so nothing was delivered.
325
+ // Visible to us, meaningless to a visitor. It disappears in production.
326
+ if (body.demo) status.textContent += ' ⚠︎';
327
+ } else if (body.error === 'anonymous' || body.error === 'unreachable') {
328
+ // The platform refuses by name — no name, no way back to the sender —
329
+ // and the sentence for each is the one the field already carries.
330
+ const at = body.error === 'anonymous' ? 'name' : 'contact';
331
+ const input = form.elements.namedItem(at) as HTMLInputElement | null;
332
+ showError(at, input?.dataset.message ?? '');
333
+ input?.focus();
334
+ } else {
335
+ form.setAttribute('data-state', 'error');
336
+ status.textContent = body.message ?? form.dataset.error ?? '';
337
+ }
338
+ } catch {
339
+ form.setAttribute('data-state', 'error');
340
+ status.textContent = form.dataset.error ?? '';
341
+ } finally {
342
+ button.disabled = false;
343
+ label.textContent = original;
344
+ }
345
+ });
346
+ }
347
+ </script>
@@ -0,0 +1,69 @@
1
+ ---
2
+ /**
3
+ * The business's opening hours, read from the module's settings.
4
+ *
5
+ * The owner keeps the hours in the panel, under the diary; publish writes
6
+ * that document to `jtk/bookings.json`. This is the one place a page should
7
+ * take its hours from — a block that types them again as text is a second
8
+ * copy, and the platform's judge advises against it (JTK_W_MODULE_TWIN).
9
+ *
10
+ * A repository nobody has published yet has no `jtk/bookings.json`, so the
11
+ * file is read through a glob rather than imported: absent, the component
12
+ * draws nothing and the build goes on.
13
+ */
14
+ interface Row {
15
+ day: string;
16
+ opens: string;
17
+ closes: string;
18
+ }
19
+ interface Props {
20
+ locale: 'uk' | 'de' | 'en';
21
+ /** The heading over the list; empty draws none. */
22
+ title?: string;
23
+ }
24
+ const { locale, title = '' } = Astro.props;
25
+
26
+ const files = import.meta.glob<{ blocks?: { hours?: Row[] }[] }>('/jtk/bookings.json', {
27
+ eager: true,
28
+ import: 'default',
29
+ });
30
+ const settings = Object.values(files)[0];
31
+ const rows: Row[] = settings?.blocks?.[0]?.hours ?? [];
32
+
33
+ const ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
34
+ const DAYS: Record<Props['locale'], Record<string, string>> = {
35
+ uk: { monday: 'Понеділок', tuesday: 'Вівторок', wednesday: 'Середа', thursday: 'Четвер', friday: 'Пʼятниця', saturday: 'Субота', sunday: 'Неділя' },
36
+ de: { monday: 'Montag', tuesday: 'Dienstag', wednesday: 'Mittwoch', thursday: 'Donnerstag', friday: 'Freitag', saturday: 'Samstag', sunday: 'Sonntag' },
37
+ en: { monday: 'Monday', tuesday: 'Tuesday', wednesday: 'Wednesday', thursday: 'Thursday', friday: 'Friday', saturday: 'Saturday', sunday: 'Sunday' },
38
+ };
39
+ const CLOSED: Record<Props['locale'], string> = { uk: 'зачинено', de: 'geschlossen', en: 'closed' };
40
+
41
+ // One line per day, in the week's order; a day with two rows — a break —
42
+ // is two stretches on one line; a day with none is closed.
43
+ const week = ORDER.map((day) => ({
44
+ day,
45
+ label: DAYS[locale][day],
46
+ stretches: rows.filter((row) => row.day === day).map((row) => `${row.opens}–${row.closes}`),
47
+ }));
48
+ ---
49
+ {rows.length > 0 && (
50
+ <section class="hours">
51
+ {title && <h2 class="hours__title">{title}</h2>}
52
+ <dl class="hours__list">
53
+ {week.map((one) => (
54
+ <div class="hours__row" data-day={one.day}>
55
+ <dt class="hours__day">{one.label}</dt>
56
+ <dd class="hours__time">{one.stretches.length === 0 ? CLOSED[locale] : one.stretches.join(', ')}</dd>
57
+ </div>
58
+ ))}
59
+ </dl>
60
+ </section>
61
+ )}
62
+
63
+ <style>
64
+ /* Legible and nothing more: the site's own type and rules go here. */
65
+ .hours__list { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1.25rem; margin: 0; }
66
+ .hours__row { display: contents; }
67
+ .hours__day { margin: 0; }
68
+ .hours__time { margin: 0; font-variant-numeric: tabular-nums; }
69
+ </style>
@@ -0,0 +1,185 @@
1
+ ---
2
+ /**
3
+ * A pile: several photographs laid over each other, each a little off square.
4
+ *
5
+ * ── why this is a component ─────────────────────────────────────────────────
6
+ *
7
+ * A pile is four numbers per card and it goes wrong in five predictable places,
8
+ * every one of which was found in a shipped site built by hand:
9
+ *
10
+ * · the rotated card whose corner leaves the viewport, so the whole page
11
+ * scrolls sideways on a phone — the fault `fl-check` fails a build for;
12
+ * · five overlapping photographs at 390px, which is mush;
13
+ * · four alt texts read out in a row for what is visually one thing;
14
+ * · the largest picture in the pile not marked as the page's LCP image;
15
+ * · angles typed by hand, which come out the same in every project, or drawn
16
+ * at random, which come out different on every rebuild.
17
+ *
18
+ * So the arrangement is here and the pictures are yours. Children are laid into
19
+ * a 12×12 grid by their order — the first is at the back, the last is on top —
20
+ * and each gets its place and its tilt from that position. Anything can be a
21
+ * child: a `<Shot>`, a badge, a card, their own mark.
22
+ *
23
+ * <Pile>
24
+ * <Shot name="room" />
25
+ * <Shot name="work" eager />
26
+ * <Shot name="hands" decorative />
27
+ * <p class="badge">под ключ</p>
28
+ * </Pile>
29
+ *
30
+ * ── what it is for ─────────────────────────────────────────────────────────
31
+ *
32
+ * `portrait-led` fails on a weak portrait, which on the outbound track is
33
+ * common: a scraped feed often has no single frame that can carry a screen. A
34
+ * pile is the answer — four frames share the work, so none of them has to be
35
+ * the one good photograph nobody has.
36
+ *
37
+ * It is the *hand* of the page, and it works by contrast: everything under it
38
+ * is set square. See references/shapes.md — if everything tilts, nothing does.
39
+ */
40
+
41
+ interface Props {
42
+ /**
43
+ * The pile's own frame. Rows are a twelfth of it each, so this is what
44
+ * decides how tall the cards are — not the pictures inside them.
45
+ */
46
+ ratio?: string;
47
+ /**
48
+ * Multiplies every card's tilt. 0 lays the pile out square, which is a real
49
+ * choice — the overlap alone is often enough.
50
+ */
51
+ tilt?: number;
52
+ class?: string;
53
+ }
54
+
55
+ const { ratio = '4 / 3', tilt, class: className } = Astro.props;
56
+ ---
57
+
58
+ <div
59
+ class:list={['pile', className]}
60
+ style={[`aspect-ratio:${ratio}`, tilt !== undefined ? `--pile-tilt:${tilt}` : '']
61
+ .filter(Boolean)
62
+ .join(';')}
63
+ >
64
+ <slot />
65
+ </div>
66
+
67
+ <style>
68
+ /*
69
+ * A 12×12 grid with definite rows, so a card's height comes from the pile
70
+ * rather than from the picture in it. That is what makes the arrangement hold
71
+ * whatever photographs it is given — a feed arrives in every orientation, and
72
+ * a pile sized by its contents is a pile that reflows differently per client.
73
+ */
74
+ .pile {
75
+ display: grid;
76
+ grid-template-columns: repeat(12, 1fr);
77
+ grid-template-rows: repeat(12, 1fr);
78
+ inline-size: 100%;
79
+ }
80
+
81
+ /*
82
+ * Order is depth: the first child is at the back. It is the one mental model
83
+ * that survives somebody editing this six months from now, and it costs one
84
+ * declaration.
85
+ *
86
+ * Every child selector here is `:global`, and it has to be. Slotted children
87
+ * belong to the page that wrote them, not to this component, so a plain
88
+ * `.pile > *` compiles to a scope attribute they do not carry and the whole
89
+ * arrangement silently does nothing — the cards land in auto-placed cells and
90
+ * the pile looks like a small broken grid. It cost an afternoon once.
91
+ */
92
+ .pile > :global(*) {
93
+ margin: 0;
94
+ block-size: 100%;
95
+ min-inline-size: 0;
96
+ rotate: calc(var(--tilt, 4deg) * var(--pile-n, 0) * var(--pile-tilt, 1));
97
+ }
98
+
99
+ /*
100
+ * The arrangement. Five places, because a sixth card is a collage rather than
101
+ * a pile and stops reading as one thing.
102
+ *
103
+ * Nothing reaches the outer column on both sides at once, which is what keeps
104
+ * a rotated corner inside the box. Move a card by overriding its grid-area and
105
+ * --pile-n from the page; that is a design decision, not a fork of this file.
106
+ */
107
+ .pile > :global(:nth-child(1)) {
108
+ grid-area: 2 / 1 / 9 / 6;
109
+ --pile-n: -1;
110
+ z-index: 1;
111
+ }
112
+
113
+ .pile > :global(:nth-child(2)) {
114
+ grid-area: 1 / 6 / 7 / 12;
115
+ --pile-n: 0.9;
116
+ z-index: 2;
117
+ }
118
+
119
+ .pile > :global(:nth-child(3)) {
120
+ grid-area: 6 / 3 / 13 / 8;
121
+ --pile-n: 1.2;
122
+ z-index: 3;
123
+ }
124
+
125
+ .pile > :global(:nth-child(4)) {
126
+ grid-area: 5 / 8 / 12 / 13;
127
+ --pile-n: -0.8;
128
+ z-index: 4;
129
+ }
130
+
131
+ /* The small one — a badge, a mark, a phone. It carries the biggest angle
132
+ because a small object can. */
133
+ .pile > :global(:nth-child(5)) {
134
+ grid-area: 8 / 1 / 12 / 4;
135
+ --pile-n: 1.7;
136
+ z-index: 5;
137
+ }
138
+
139
+ /*
140
+ * The pile sizes its children, so a <Shot> inside one fills the cell it was
141
+ * given rather than keeping its own proportions. This reaches into that
142
+ * component on purpose: a layout component that cannot size what it lays out
143
+ * is a layout component that only works on one set of photographs.
144
+ */
145
+ .pile :global(.shot),
146
+ .pile :global(.shot__frame),
147
+ .pile :global(.shot--natural img) {
148
+ block-size: 100%;
149
+ }
150
+
151
+ .pile :global(.shot--natural img) {
152
+ object-fit: var(--shot-fit, cover);
153
+ }
154
+
155
+ /*
156
+ * On a phone a pile is mush.
157
+ *
158
+ * Five overlapping photographs at 390px is four thumbnails and an argument.
159
+ * So the overlap goes and the tilt stays: two columns, square-ish cells, half
160
+ * the angle — enough that the hand is still visible, little enough that the
161
+ * corners stay inside a narrow screen.
162
+ *
163
+ * The fifth card is dropped rather than shrunk. It is the small one, it was
164
+ * decoration in a composition that no longer exists, and a 90px cell is worse
165
+ * than nothing.
166
+ */
167
+ @media (max-width: 700px) {
168
+ .pile {
169
+ grid-template-columns: 1fr 1fr;
170
+ grid-template-rows: none;
171
+ gap: 0.75rem;
172
+ aspect-ratio: auto !important;
173
+ }
174
+
175
+ .pile > :global(*) {
176
+ grid-area: auto !important;
177
+ aspect-ratio: 4 / 5;
178
+ rotate: calc(var(--tilt, 4deg) * var(--pile-n, 0) * var(--pile-tilt, 1) * 0.5);
179
+ }
180
+
181
+ .pile > :global(:nth-child(n + 5)) {
182
+ display: none;
183
+ }
184
+ }
185
+ </style>