@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,50 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { BUSINESS, INDEXABLE, PAGES, fullAddress } from '../data/site';
3
+ import { allListed, href } from '../lib/entries';
4
+
5
+ /**
6
+ * A plain-text summary of the business for a language model that lands on the
7
+ * site. No engine promises to read it; it costs one route, and for a client
8
+ * whose pitch is «be findable» it is a reasonable thing to ship.
9
+ *
10
+ * Built from the same data as the page and the structured data, so it cannot
11
+ * contradict either. Empty while INDEXABLE is false — a preview should not be
12
+ * summarising itself to anyone.
13
+ */
14
+ export const GET: APIRoute = async () => {
15
+ if (!INDEXABLE) {
16
+ return new Response('', { headers: { 'content-type': 'text/plain; charset=utf-8' } });
17
+ }
18
+
19
+ // What the owner writes, listed under its own heading. An assistant that
20
+ // lands here is looking for what this business has said, and a title with an
21
+ // address is the shortest honest form of it.
22
+ const collections = await allListed();
23
+
24
+ const lines = [
25
+ `# ${BUSINESS.name}`,
26
+ '',
27
+ `> ${BUSINESS.description}`,
28
+ '',
29
+ fullAddress,
30
+ BUSINESS.phoneDisplay,
31
+ BUSINESS.email,
32
+ BUSINESS.priceRange,
33
+ '',
34
+ '## Pages',
35
+ ...PAGES.map((p) => `- ${p.path}: ${p.summary}`),
36
+ ...collections.flatMap(({ collection, entries }) =>
37
+ entries.length === 0
38
+ ? []
39
+ : [
40
+ '',
41
+ `## ${collection.label}`,
42
+ ...entries.map((entry) => `- ${href(collection, entry)}: ${String(entry.data.title ?? '')}`),
43
+ ],
44
+ ),
45
+ ].filter((line) => line !== undefined && line !== null);
46
+
47
+ return new Response(`${lines.join('\n')}\n`, {
48
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
49
+ });
50
+ };
@@ -0,0 +1,59 @@
1
+ ---
2
+ /**
3
+ * A privacy page, as a starting point.
4
+ *
5
+ * ── what the platform requires, which is one line of this file ──────────────
6
+ *
7
+ * **The link, and nothing else.** The platform walks the built artifact looking
8
+ * for `PROCESSING_URL`, and it does not read a word around it. Everything else
9
+ * on this page — the sections, the wording, whether this file is used at all —
10
+ * belongs to the business, and grading it is neither a website builder's job
11
+ * nor within its competence.
12
+ *
13
+ * So this is a default and not a specification. Rewrite it, restyle it, replace
14
+ * it with a page written by somebody's lawyer, or put the link on a privacy
15
+ * page that already exists and delete this file. All of those are correct.
16
+ *
17
+ * What is not correct is dropping the link: it is the one sentence here that is
18
+ * about somebody other than the business, and without it nothing on the site
19
+ * tells a visitor who is holding what they typed.
20
+ *
21
+ * ── and it is not a wall ────────────────────────────────────────────────────
22
+ *
23
+ * A site missing this is **warned about and launched anyway**. Publishing a
24
+ * notice is the controller's obligation — the business's — and what makes the
25
+ * platform's own handling lawful is the agreement with them rather than a page
26
+ * here. See wiki/34: gate on our own conduct, never on somebody else's
27
+ * compliance. The one thing publishing does refuse is a form with nowhere to
28
+ * send what people write in it, which is a broken product rather than a legal
29
+ * opinion.
30
+ */
31
+ import Layout from '../layouts/Layout.astro';
32
+ import { PRIVACY } from '../copy/{{LOCALE}}';
33
+ import { PROCESSING_URL } from '../content/blocks';
34
+ ---
35
+
36
+ <Layout title={PRIVACY.title} description={PRIVACY.intro}>
37
+ <main class="prose" data-jtk-fixed>
38
+ <h1>{PRIVACY.title}</h1>
39
+ <p class="lead">{PRIVACY.intro}</p>
40
+
41
+ <h2>{PRIVACY.formTitle}</h2>
42
+ <p>{PRIVACY.formBody}</p>
43
+
44
+ <h2>{PRIVACY.whoTitle}</h2>
45
+ <p>
46
+ {PRIVACY.whoBody}{' '}
47
+ <a href={PROCESSING_URL} rel="noreferrer">{PROCESSING_URL.replace('https://', '')}</a>
48
+ </p>
49
+
50
+ <h2>{PRIVACY.askTitle}</h2>
51
+ <p>{PRIVACY.askBody}</p>
52
+
53
+ {/*
54
+ The business's own. Left visible rather than commented out, so that an
55
+ empty one is noticed before launch rather than after.
56
+ */}
57
+ <h2>{PRIVACY.ownTitle}</h2>
58
+ </main>
59
+ </Layout>
@@ -0,0 +1,21 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { INDEXABLE, canonicalFor } from '../data/site';
3
+
4
+ /**
5
+ * Driven by the same switch as the robots meta tag, the sitemap and llms.txt —
6
+ * see src/data/site.ts. One flag, because a site that ships still telling
7
+ * Google to go away is always a site where two of the four were flipped.
8
+ *
9
+ * AI crawlers are deliberately not excluded. Note that Cloudflare's *managed*
10
+ * robots.txt, if it is on for the zone, blocks them regardless of this file —
11
+ * check the zone setting too.
12
+ */
13
+ export const GET: APIRoute = () => {
14
+ const body = INDEXABLE
15
+ ? ['User-agent: *', 'Allow: /', '', `Sitemap: ${canonicalFor('/sitemap.xml')}`, '']
16
+ : ['User-agent: *', 'Disallow: /', ''];
17
+
18
+ return new Response(body.join('\n'), {
19
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
20
+ });
21
+ };
@@ -0,0 +1,50 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { INDEXABLE, PAGES, canonicalFor } from '../data/site';
3
+ import { allListed, href } from '../lib/entries';
4
+
5
+ /**
6
+ * The pages are written by hand in `PAGES` rather than generated, so that
7
+ * adding a page and forgetting to list it is a visible omission in one file
8
+ * instead of a silent one across the build.
9
+ *
10
+ * The entries are not, and cannot be: nobody writes them here — the owner makes
11
+ * them in the admin — so they are read from the collections, which is the only
12
+ * list of them that exists. A hidden entry is absent, because `allListed` is
13
+ * how anything here enumerates them.
14
+ *
15
+ * With INDEXABLE off the file is still served, but empty — an empty sitemap
16
+ * says «nothing to crawl», where a 404 says «try again later».
17
+ */
18
+ export const GET: APIRoute = async () => {
19
+ const urls: { loc: string; priority: number }[] = [];
20
+
21
+ if (INDEXABLE) {
22
+ for (const page of PAGES) {
23
+ urls.push({ loc: canonicalFor(page.path), priority: page.priority });
24
+ }
25
+ for (const { collection, entries } of await allListed()) {
26
+ for (const entry of entries) {
27
+ // Below every page of the site and above nothing: an entry is worth
28
+ // crawling and is not what the site is for.
29
+ urls.push({ loc: canonicalFor(href(collection, entry)), priority: 0.5 });
30
+ }
31
+ }
32
+ }
33
+
34
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
35
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
36
+ ${urls
37
+ .map(
38
+ (url) => ` <url>
39
+ <loc>${url.loc}</loc>
40
+ <priority>${url.priority.toFixed(1)}</priority>
41
+ </url>`,
42
+ )
43
+ .join('\n')}
44
+ </urlset>
45
+ `;
46
+
47
+ return new Response(body, {
48
+ headers: { 'content-type': 'application/xml; charset=utf-8' },
49
+ });
50
+ };
@@ -0,0 +1,411 @@
1
+ /*
2
+ * Base for {{NAME}}.
3
+ *
4
+ * Deliberately plain. The palette, the type and the layout are decided per
5
+ * variant from the brief — nothing in this file is a design suggestion, and the
6
+ * default look must never be mistaken for a direction somebody chose.
7
+ *
8
+ * What it does establish is the token vocabulary the components expect, so that
9
+ * a variant fills these in rather than inventing new names. One rule matters
10
+ * more than the rest and is easy to get wrong:
11
+ *
12
+ * --accent the FILL. Buttons, bars, badges — anything ink sits ON.
13
+ * --accent-ink the same signal as TEXT. On a light ground it is a step
14
+ * DARKER; on a dark ground it inverts. A component that puts
15
+ * words on --accent is a contrast bug, not a preference.
16
+ *
17
+ * Surfaces run in one direction: --paper is the MIDDLE tone, --panel is LIFTED
18
+ * above it, --paper-deep is RECESSED below it. "Deep" means recessed, not dark.
19
+ *
20
+ * The preview shell drives --accent live from the studio bar, so a variant's
21
+ * brand colour must route through this property and everything derived from it
22
+ * should be derived in CSS rather than hard-coded — otherwise the picker moves
23
+ * one colour and leaves five behind.
24
+ */
25
+
26
+ /*
27
+ * Edges, entrances, and the host class a block-scope ground needs. Kept in its
28
+ * own file because it is a catalogue rather than a base: see
29
+ * `docs/surface.md` of @jtakeit/astro.
30
+ */
31
+ @import './surface.css';
32
+
33
+ :root {
34
+ --paper: #ffffff;
35
+ --panel: #ffffff;
36
+ --paper-deep: #f4f2ee;
37
+
38
+ --ink: #16130f;
39
+ --ink-quiet: #6f6a63;
40
+ --line: #e2ded7;
41
+
42
+ --accent: #2f5bff;
43
+ --accent-ink: #1c3fd6;
44
+ --danger: #b3261e;
45
+
46
+ /*
47
+ * How wide things are allowed to be — two different questions with two
48
+ * different answers, and collapsing them is why a page built at 1440 looks
49
+ * stranded on a 27-inch monitor.
50
+ *
51
+ * --shell the layout's outer width. It GROWS with the screen.
52
+ * --measure how long a line of text may get. It does NOT: past about 65
53
+ * characters the eye loses the start of the next line.
54
+ *
55
+ * A shell capped at 1200px is 47% of a 2560px screen, and everything inside
56
+ * it reads as an island. Cap the measure instead and let the composition use
57
+ * the space. See the Composition floor in the astro-kit skill.
58
+ */
59
+ --shell: min(92rem, 92vw);
60
+ --measure: 34rem;
61
+
62
+ --r-1: 4px;
63
+ --r-2: 8px;
64
+ --r-3: 14px;
65
+ --r-4: 22px;
66
+
67
+ --dur: 180ms;
68
+ --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
69
+
70
+ /*
71
+ * The surface: the ground a page is printed on, the edge a block is drawn
72
+ * with, and how a block arrives. Three catalogues, one reference —
73
+ * `docs/surface.md` of @jtakeit/astro — and the same discipline as
74
+ * the rest of this file: a component reads these names rather than typing a
75
+ * value of its own.
76
+ *
77
+ * --pattern-ink is the only colour here on purpose. Everything the ground
78
+ * draws is that one token at that one opacity, which is what keeps a
79
+ * background inside the variant's palette instead of beside it. <Pattern>
80
+ * sets its own geometry per ground; these are the parts a variant tunes.
81
+ */
82
+ --pattern-ink: var(--ink);
83
+
84
+ --edge-ink: var(--line);
85
+ --edge-weight: 1px;
86
+ /* How much of a corner `corners` draws, and how deep `notch` cuts. */
87
+ --edge-corner: 18px;
88
+ --edge-offset: 7px;
89
+
90
+ --reveal-dur: 0.7s;
91
+ --reveal-dist: 24px;
92
+ /* Between one staggered child and the next. Past ~120ms a list reads as slow. */
93
+ --reveal-step: 80ms;
94
+
95
+ /*
96
+ * What a photograph is printed in. Six treatments in <Shot>, all built from
97
+ * these four and nothing else — see `docs/photos.md` of @jtakeit/astro.
98
+ *
99
+ * --photo-shadow carries the ground's hue on purpose. A neutral black in the
100
+ * shadows of a photograph on a warm page turns them grey, which is the same
101
+ * fault as a neutral shadow under a card and reads the same way: dirty rather
102
+ * than photographed.
103
+ */
104
+ --photo-shadow: color-mix(in srgb, var(--ink) 78%, var(--accent));
105
+ --photo-light: var(--paper);
106
+ --photo-ink: var(--accent);
107
+ /* What `recede` veils a picture with, so copy can sit on it. */
108
+ --photo-veil: var(--paper);
109
+ --photo-amount: 1;
110
+
111
+ /*
112
+ * The hand.
113
+ *
114
+ * How far off square something laid down by a person sits. Measured off the
115
+ * sites that do this well, it is ±3–6°: past about 8° it stops reading as a
116
+ * hand and starts reading as a template, and the difference between a page
117
+ * that looks made and one that looks generated is that single number.
118
+ *
119
+ * It is a token because it has to be the *same* number everywhere on a page.
120
+ * Two elements at two angles nobody chose is the thing that makes a
121
+ * composition look accidental rather than casual.
122
+ */
123
+ --tilt: 4deg;
124
+ }
125
+
126
+ *,
127
+ *::before,
128
+ *::after {
129
+ box-sizing: border-box;
130
+ }
131
+
132
+ /*
133
+ * An in-page link is an instant jump unless told otherwise, and on a long
134
+ * landing page a jump reads as a page reload: the visitor loses the thread and
135
+ * often scrolls back up to check what happened. Smooth scrolling is the
136
+ * expected behaviour here, and it is switched off wholesale for anyone who
137
+ * asked for less motion.
138
+ */
139
+ @media (prefers-reduced-motion: no-preference) {
140
+ html {
141
+ scroll-behavior: smooth;
142
+ }
143
+ }
144
+
145
+ /*
146
+ * Where a link lands. Without this the target's border box goes to the top of
147
+ * the screen — which on a section with generous top padding means arriving at
148
+ * an empty screen, with the heading the visitor came for still below the fold.
149
+ * Every anchor target sets its own offset: a positive one to keep air above a
150
+ * panel's visible edge, a negative one to pull past padding that carries no
151
+ * meaning. See the Composition floor in the astro-kit skill.
152
+ */
153
+ [id] {
154
+ scroll-margin-block-start: 1.5rem;
155
+ }
156
+
157
+ body {
158
+ margin: 0;
159
+ background: var(--paper);
160
+ color: var(--ink);
161
+ font-family: system-ui, sans-serif;
162
+ -webkit-font-smoothing: antialiased;
163
+ /* Nothing on the page scrolls sideways. Wide things scroll inside their own
164
+ container; the body never does. */
165
+ overflow-x: hidden;
166
+ }
167
+
168
+ img,
169
+ picture,
170
+ svg,
171
+ video {
172
+ max-inline-size: 100%;
173
+ }
174
+
175
+ a {
176
+ color: var(--accent-ink);
177
+ }
178
+
179
+ button,
180
+ input,
181
+ textarea,
182
+ select {
183
+ font: inherit;
184
+ color: inherit;
185
+ }
186
+
187
+ /*
188
+ * Autofill. Chrome and Safari repaint a filled field with their own background
189
+ * and their own text colour, which on any palette that is not white turns a
190
+ * designed form into a white-and-yellow one the moment the browser helps. There
191
+ * is no property to switch it off: the background has to be covered by an inset
192
+ * shadow, and the text colour set through -webkit-text-fill-color, which wins
193
+ * over `color`. The long transition is the standard trick for the repaint that
194
+ * fires on submit.
195
+ *
196
+ * These are the tokens the field itself uses. A variant that gives its inputs a
197
+ * different surface overrides this rule with the same two properties rather than
198
+ * deleting it.
199
+ */
200
+ input:-webkit-autofill,
201
+ input:-webkit-autofill:hover,
202
+ input:-webkit-autofill:focus,
203
+ textarea:-webkit-autofill,
204
+ select:-webkit-autofill {
205
+ -webkit-text-fill-color: var(--ink);
206
+ -webkit-box-shadow: 0 0 0 100vmax var(--paper-deep) inset;
207
+ box-shadow: 0 0 0 100vmax var(--paper-deep) inset;
208
+ caret-color: var(--ink);
209
+ transition: background-color 100000s ease-in-out 0s;
210
+ }
211
+
212
+ /* Visible keyboard focus is a floor, not a feature. */
213
+ :focus-visible {
214
+ outline: 2px solid var(--accent-ink);
215
+ outline-offset: 2px;
216
+ }
217
+
218
+ /*
219
+ * Type scales with the screen and then stops. A bare vw is unbounded and a
220
+ * fixed rem never grows — clamp() is the whole answer, and a heading that is
221
+ * the same size on a laptop and on a 27-inch monitor is the commonest sign
222
+ * that nobody opened the page wide.
223
+ */
224
+ h1 {
225
+ font-size: clamp(2rem, 1.2rem + 3vw, 4.5rem);
226
+ line-height: 1.05;
227
+ text-wrap: balance;
228
+ }
229
+
230
+ .btn {
231
+ padding: 0.75rem 1.5rem;
232
+ color: #fff;
233
+ background: var(--accent);
234
+ border: none;
235
+ border-radius: var(--r-2);
236
+ cursor: pointer;
237
+ transition: filter var(--dur) var(--ease);
238
+ }
239
+
240
+ .btn:hover {
241
+ filter: brightness(1.08);
242
+ }
243
+
244
+ .btn:disabled {
245
+ opacity: 0.6;
246
+ cursor: default;
247
+ }
248
+
249
+ /*
250
+ * Reduced motion means the finished state, never a broken one. The catalogue in
251
+ * surface.css lands each of its own entrances in the right finished place; this
252
+ * is the floor under everything else.
253
+ */
254
+ @media (prefers-reduced-motion: reduce) {
255
+ *,
256
+ *::before,
257
+ *::after {
258
+ animation-duration: 0.01ms !important;
259
+ animation-iteration-count: 1 !important;
260
+ transition-duration: 0.01ms !important;
261
+ }
262
+ }
263
+
264
+ /* ── the page's sections ─────────────────────────────────────────────────────
265
+ *
266
+ * A starting point, not a design. `Blocks.astro` renders whatever shape the
267
+ * content document has, and this gives it a readable measure, some air and a
268
+ * heading scale so it can be looked at before anybody has styled it. The design
269
+ * pass replaces all of it; what it must not remove is the annotation, which
270
+ * lives in the markup rather than here.
271
+ */
272
+
273
+ .hero,
274
+ .band {
275
+ padding: clamp(3rem, 8vw, 7rem) 1.25rem;
276
+ }
277
+
278
+ .hero__inner,
279
+ .band__inner {
280
+ width: var(--shell);
281
+ max-width: 100%;
282
+ margin-inline: auto;
283
+ }
284
+
285
+ .band:nth-of-type(even) {
286
+ background: var(--paper-deep);
287
+ }
288
+
289
+ .eyebrow {
290
+ margin: 0 0 0.75rem;
291
+ font-size: 0.8125rem;
292
+ letter-spacing: 0.08em;
293
+ text-transform: uppercase;
294
+ color: var(--ink-quiet);
295
+ }
296
+
297
+ .lead {
298
+ max-width: var(--measure);
299
+ margin: 1rem 0 0;
300
+ font-size: clamp(1.0625rem, 2.4vw, 1.25rem);
301
+ line-height: 1.6;
302
+ color: var(--ink-quiet);
303
+ }
304
+
305
+ .quiet {
306
+ margin: 0.25rem 0 0;
307
+ color: var(--ink-quiet);
308
+ }
309
+
310
+ .prose {
311
+ max-width: var(--measure);
312
+ margin-top: 1rem;
313
+ line-height: 1.7;
314
+ }
315
+
316
+ .prose > * + * {
317
+ margin-top: 0.85em;
318
+ }
319
+
320
+ /*
321
+ * A picture in a body, and pictures side by side.
322
+ *
323
+ * These two class names are the whole of what a post's pictures can look like,
324
+ * and `figures.mjs` is what makes them out of plain markdown: one image alone
325
+ * in a paragraph is a figure, several in *one* paragraph are a row, and the
326
+ * caption is markdown's own title slot.
327
+ *
328
+ * Restyle them. Do not invent a third — the point of a small named set is that
329
+ * what a body can look like was decided once by the kit rather than again by
330
+ * every site, and the editor can only offer what exists here.
331
+ */
332
+ .fl-figure {
333
+ margin: 2rem 0;
334
+ }
335
+
336
+ .fl-figure img {
337
+ display: block;
338
+ inline-size: 100%;
339
+ block-size: auto;
340
+ border-radius: var(--radius, 0);
341
+ }
342
+
343
+ .fl-figure figcaption {
344
+ margin-top: 0.6rem;
345
+ font-size: 0.875rem;
346
+ line-height: 1.5;
347
+ color: var(--ink-quiet);
348
+ }
349
+
350
+ /*
351
+ * A row wraps rather than shrinking past legibility: three photographs on a
352
+ * phone are three photographs, not three stamps. `--3` and `--4` say how many
353
+ * were asked for; how many actually fit is the screen's to decide.
354
+ */
355
+ .fl-row {
356
+ display: grid;
357
+ gap: clamp(0.5rem, 1.5vw, 1rem);
358
+ grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
359
+ margin: 2rem 0;
360
+ }
361
+
362
+ .fl-row .fl-figure {
363
+ margin: 0;
364
+ }
365
+
366
+ /* Two is the one count that must not wrap on a small screen: a pair is a
367
+ comparison, and a comparison stacked is two pictures. */
368
+ .fl-row--2 {
369
+ grid-template-columns: repeat(2, 1fr);
370
+ }
371
+
372
+ .actions {
373
+ margin: 1.75rem 0 0;
374
+ }
375
+
376
+ .items {
377
+ display: grid;
378
+ gap: 1.5rem clamp(1.5rem, 3vw, 2.5rem);
379
+ grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
380
+ margin: 2rem 0 0;
381
+ padding: 0;
382
+ list-style: none;
383
+ }
384
+
385
+ /* An edge needs somewhere to sit: a card with no padding gets a line hard
386
+ against its text. The design decides the real value; this keeps the starting
387
+ point honest. */
388
+ .item[data-edge] {
389
+ padding: 1.25rem;
390
+ }
391
+
392
+ .item h3 {
393
+ margin: 0;
394
+ font-size: 1.0625rem;
395
+ }
396
+
397
+ .item p {
398
+ margin: 0.5rem 0 0;
399
+ line-height: 1.65;
400
+ color: var(--ink-quiet);
401
+ }
402
+
403
+ /* The hero sits on the field, so its own padding has to survive the canvas
404
+ * behind it — the component fills its box and puts the content above. */
405
+ .field.hero {
406
+ padding: 0;
407
+ }
408
+
409
+ .field.hero .field__content {
410
+ padding: clamp(3rem, 8vw, 7rem) 1.25rem;
411
+ }