@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,249 @@
1
+ # Collections — a blog, a portfolio, a price list
2
+
3
+ ## Only when it is asked for
4
+
5
+ **Never add a collection because a site could have one.** No brief that does not
6
+ name it gets a blog, and no site gets one "for later". The scaffold declares
7
+ none, and that is the correct state for almost every site this studio ships.
8
+
9
+ The reason is not tidiness. A collection is a standing obligation on the person
10
+ who owns the site: an empty blog on a live page says the business stopped
11
+ caring in March, and a blog with three posts from a year ago says it louder. A
12
+ landing page that never mentions one says nothing at all. Handing somebody a
13
+ weekly job they did not ask for is worse than handing them a smaller site.
14
+
15
+ So the bar is a sentence from the person commissioning the work — the studio,
16
+ the developer, the client — that names the thing: *«потрібен блог»*, *«хочу
17
+ розділ з роботами»*. Not "it would be nice", not inferred from a vertical, not
18
+ because the design has room. If the brief is silent, the answer is silence.
19
+
20
+ ---
21
+
22
+ A collection is a set of **entries the owner creates in the admin** and this
23
+ repository renders. It is the one place the admin makes something the
24
+ repository did not, and the line is drawn once:
25
+
26
+ > **This repository declares the shape. The admin creates the entries.**
27
+
28
+ Everywhere else, which pages a site has is yours: pages arrive by import, and
29
+ the admin has no "add a page" — a page invented there would have no route to
30
+ render it. A collection changes exactly one thing: you write the shape and the
31
+ two routes, and the owner writes the posts.
32
+
33
+ So there is no "blog feature" in the platform. A blog is one collection whose
34
+ prefix is `/blog`, ordered by date. A portfolio, a menu, a vacancy board and a
35
+ price list are the same mechanism with different words.
36
+
37
+ ---
38
+
39
+ ## What you write
40
+
41
+ **One entry in `src/content/blocks.ts`**, beside the blocks:
42
+
43
+ ```ts
44
+ export const COLLECTIONS: Collection[] = [
45
+ { name: 'blog', label: 'Блог', prefix: '/blog', type: 'post',
46
+ order: { by: 'date', desc: true }, per_page: 10 },
47
+ ];
48
+ ```
49
+
50
+ **And the block type its entries are made of**, in `BLOCKS` like any other:
51
+
52
+ ```ts
53
+ {
54
+ type: 'post',
55
+ v: 1,
56
+ label: 'Допис',
57
+ fields: [
58
+ { key: 'title', label: 'Заголовок', kind: 'text', required: true, max: 90, client_editable: true, seo_sensitive: true },
59
+ { key: 'date', label: 'Дата', kind: 'date', required: true, client_editable: true, no_tap_target: true },
60
+ { key: 'excerpt', label: 'Анонс', kind: 'textarea', max: 200, client_editable: true, no_tap_target: true },
61
+ { key: 'cover', label: 'Обкладинка', kind: 'media', client_editable: true },
62
+ { key: 'body', label: 'Текст допису', kind: 'markdown', max: 40000, client_editable: true },
63
+ ],
64
+ }
65
+ ```
66
+
67
+ **And the listing page** — `src/pages/blog/index.astro` — which is an ordinary
68
+ page of yours, designed like every other:
69
+
70
+ ```astro
71
+ ---
72
+ import { listed, href, collectionNamed } from '../../lib/entries';
73
+ const blog = collectionNamed('blog');
74
+ const posts = await listed('blog');
75
+ ---
76
+ ```
77
+
78
+ `listed()` is the only thing that enumerates entries, and it does two things you
79
+ must not do by hand: it drops the ones nobody has finished, and it sorts by the
80
+ collection's own order.
81
+
82
+ ## What you get without writing it
83
+
84
+ | | |
85
+ | --- | --- |
86
+ | `src/pages/[...entry].astro` | one entry of **any** collection. Ships with the scaffold; a new collection needs no new route |
87
+ | `/blog/rss.xml` | one feed per collection |
88
+ | `sitemap.xml`, `llms.txt` | the entries are in both, the unfinished ones in neither |
89
+ | `dist/_meta.json` | the list of unfinished entries, which is what lets the studio's edge keep them private |
90
+
91
+ The entry route is a **starting point, not a design** — exactly like
92
+ `Blocks.astro`. Replace what is inside `<article>`. What must survive is the
93
+ annotation.
94
+
95
+ ---
96
+
97
+ ## The five reserved keys
98
+
99
+ An entry's block type may declare whatever this business needs, and five names
100
+ are spoken for, because the listing, the feed, the sitemap, the card a messenger
101
+ draws and the admin's own list of entries all read them by name:
102
+
103
+ | | |
104
+ | --- | --- |
105
+ | `title` | text, **required**. Everything above needs one. |
106
+ | `date` | date. Required if the collection is ordered by it. |
107
+ | `excerpt` | textarea. The sentence under the title in a listing. |
108
+ | `cover` | media, one picture — never `multiple`. The card, the listing, `og:image`. |
109
+ | `body` | markdown. The entry itself. |
110
+
111
+ Leave out any that make no sense — a set of works has no excerpt. What you may
112
+ not do is give one of those names to something else; `jtk catalogue` refuses it.
113
+
114
+ Four more names belong to the entry's *file* rather than to a field of it, and
115
+ are refused as field keys for the same reason: `collection`, `visible`, `seo`,
116
+ `path`.
117
+
118
+ ## Annotation: two rules
119
+
120
+ **An entry's document is one block**, so its paths are always
121
+ `blocks[0].<field>` — `blocks[0].title`, `blocks[0].body`.
122
+
123
+ **A listing page carries no annotations at all.** The titles on it are other
124
+ pages' words; a tap there would send a path the admin resolves against the
125
+ listing's own document, which is a different page. `jtk catalogue` refuses a
126
+ built page that has annotations and no content document of its own. The owner
127
+ edits an entry by opening it.
128
+
129
+ A field the entry page does not render is `no_tap_target: true` — that is
130
+ usually `date` and `excerpt`, which live on the listing rather than on the post.
131
+
132
+ ## Pictures in a body
133
+
134
+ Ordinary markdown, and the address is a media key exactly as everywhere else:
135
+
136
+ ```markdown
137
+ ![Заживший рукав](media/<site>/<hash>.jpg "Три тижні потому, без ретуші")
138
+ ```
139
+
140
+ The alt text is heard by whoever cannot see it; **the title slot is the
141
+ caption**, printed under the picture. Two sentences, two jobs, two slots that
142
+ CommonMark has had all along.
143
+
144
+ ### A figure, and a row
145
+
146
+ A paragraph holding nothing but pictures becomes a figure, or a row of them:
147
+
148
+ ```markdown
149
+ ![](one.jpg) one paragraph, one picture → a figure
150
+
151
+ ![](one.jpg)![](two.jpg) ONE paragraph, three → a row
152
+ ![](three.jpg)
153
+ ```
154
+
155
+ Images with **no blank line between them are one paragraph** in CommonMark, and
156
+ that is already a statement that they belong together — so it is the row, with
157
+ no syntax added. A blank line between them is somebody saying they are
158
+ separate, and the build believes them.
159
+
160
+ Written by the editor as `![](a.jpg)![](b.jpg)`, adjacent, because that is the
161
+ form that survives a round trip: one per line parses with a space between them
162
+ and would be rewritten on the next save. Both forms render identically, so an
163
+ entry written by hand may use whichever reads better in the diff.
164
+
165
+ What comes out — the whole surface, and it is deliberately small:
166
+
167
+ ```html
168
+ <figure class="fl-figure"><img …><figcaption>…</figcaption></figure>
169
+
170
+ <div class="fl-row fl-row--3">…</div>
171
+ ```
172
+
173
+ `fl-row--2`, `--3`, `--4`; four means four or more and the stylesheet wraps.
174
+ `src/styles/global.css` ships both, and a site is expected to **restyle them and
175
+ not invent a third**: what a body can look like is a decision the kit made once,
176
+ so that the editor can offer exactly what exists. `figures.mjs` is the plugin
177
+ that makes them, and its header is the argument for why none of this is a
178
+ markdown dialect — read it before adding one.
179
+
180
+ **There is no size, and that is not an oversight.** A row is a row; how wide its
181
+ pictures are is the design's answer, not the owner's. They choose which
182
+ paragraph a picture follows and what it is captioned, and nothing else.
183
+
184
+ The build downloads it beside the entry, so Astro resolves and optimises it like
185
+ every other picture on the site: with `image.layout` set in `astro.config.mjs`,
186
+ one body picture comes out as six webp variants with a `srcset`.
187
+
188
+ **On a laptop it does not build.** A clone has downloaded no media, and a
189
+ markdown image with no file is `ImageNotFound` — a hard failure, not the
190
+ labelled placeholder a missing `<Shot>` slot draws. Check against a build made
191
+ by the studio: `npx @jtakeit/astro catalogue --dist ./dist`.
192
+
193
+ ## A listing's tiles are the posts' own words
194
+
195
+ A tile draws a heading, a date and an excerpt for a post, and every one of them
196
+ belongs to **that post's document**, not the listing's. Annotated the ordinary
197
+ way they would name a field the listing page has not got — so for a long time
198
+ they carried no annotation at all, and the words an owner most wants to fix
199
+ were the one place on the site they could not tap.
200
+
201
+ `readOther` reads another page's document and writes the address into every
202
+ path it hands back:
203
+
204
+ ```astro
205
+ ---
206
+ import { readOther } from '../../lib/page';
207
+
208
+ const post = readOther('/blog/healing');
209
+ const { at, of } = post.block('post');
210
+ ---
211
+ <a href="/blog/healing" data-jtk-path={post.pathAt(at, 'title')}>{post.str(of, 'title')}</a>
212
+ ```
213
+
214
+ ```html
215
+ data-jtk-path="page:/blog/healing:blocks[0].title"
216
+ ```
217
+
218
+ The admin resolves it against that page, writes it there, and the post's own
219
+ page updates with it. `fl-check` holds the claim from the other end: an address
220
+ this site did not build, or a field that page does not offer, fails.
221
+
222
+ **Only where a page renders another page's content.** A heading you wrote in
223
+ *this* page's document is this page's, however much it looks like a tile.
224
+
225
+ ## Writing an entry by hand
226
+
227
+ Legitimate, and sometimes the fastest way to write one. The file is
228
+ `jtk/content/<prefix>/<slug>.md`, its address is where it sits, and the
229
+ next publish imports it back as the draft:
230
+
231
+ ```markdown
232
+ ---
233
+ collection: blog
234
+ visible: false
235
+ type: post
236
+ v: 1
237
+ title: Догляд після сеансу
238
+ date: "2026-08-26"
239
+ excerpt: Що робити перші два тижні.
240
+ ---
241
+
242
+ ## Перший тиждень
243
+
244
+ Не мочити, не терти рушником.
245
+ ```
246
+
247
+ `visible: false` means the site builds it and lists it nowhere; the studio's
248
+ edge serves it only to a session that is editing the site. Quote the date —
249
+ unquoted, YAML makes it a timestamp.
package/docs/css.md ADDED
@@ -0,0 +1,86 @@
1
+ # Tokens and CSS discipline
2
+
3
+ The design direction comes from `frontend-design`. This is how it is written
4
+ down so that it survives three weeks of edits.
5
+
6
+ ## One file names the colours
7
+
8
+ `src/styles/global.css` (or the variant's stylesheet) holds every colour, and
9
+ nothing outside it names a colour that is not a token. The exception is a
10
+ component that deliberately owns a foreign palette — a demo screenshot, an
11
+ embedded third-party card — and it says so in a comment.
12
+
13
+ ## The mistake this catches
14
+
15
+ A brand colour is one value doing two jobs, and the two jobs need different
16
+ values:
17
+
18
+ - **the fill** — buttons, bars, badges, anything ink sits *on*;
19
+ - **the text form** — links, small labels, numbers.
20
+
21
+ On a light ground the text form is usually a step *darker*; on a dark ground it
22
+ inverts. `nails` names them `--accent` and `--accent-strong`: the first is 3.5:1
23
+ and may never carry text, the second is 5.7:1 and carries all of it. `main`
24
+ names them `--accent` and `--accent-ink` for the same split. A new component
25
+ that puts words on the fill token is a contrast bug, not a style preference.
26
+
27
+ Write which is which in a comment at the token. It is the single rule most
28
+ likely to be broken by whoever touches the file next.
29
+
30
+ ## Surfaces run in one direction
31
+
32
+ On a light page: the background is the **middle** tone, panels and cards are
33
+ **lifted** above it, inputs and footer bands are **recessed** below it. "Deep"
34
+ means recessed, not dark. Name the tokens so that is obvious — `--paper`,
35
+ `--panel`, `--paper-deep` — because a token called `--bg-dark` on a light theme
36
+ will be used for the wrong thing within a week.
37
+
38
+ ## Warm grounds need warm shadows
39
+
40
+ A neutral black shadow on a warm ground turns the area under every card grey,
41
+ and grey under warm beige is what makes a page look dirty rather than
42
+ photographed. Define `--shadow-1/2/3` with the ground's hue in them and use
43
+ nothing else.
44
+
45
+ ## Everything else is a token too
46
+
47
+ Radii `--r-1…--r-5`, motion `--dur` / `--ease`, type scale, spacing. A bare
48
+ `150ms ease` or a hand-typed `12px` radius in a new component is a drift, not a
49
+ choice — and drift is precisely what makes a site stop looking designed after
50
+ the third round of edits.
51
+
52
+ ## Responsive
53
+
54
+ Real breakpoints, one activation point each. If a preview mode ever needs to
55
+ force a viewport, do it by changing the element's actual width (an iframe, a
56
+ container query), not by mirroring every breakpoint into a second selector —
57
+ `duckit` mirrors `@media` and `html[data-vp]`, and every layout change there has
58
+ to be made in two places for the rest of the project's life.
59
+
60
+ Small screens **drop** what stops earning its space; they do not shrink
61
+ everything uniformly. Timestamps, decorative cursors, secondary chips, a
62
+ photo collage — each goes at the width where it stops helping, with a comment at
63
+ the rule saying why.
64
+
65
+ Nothing on the page scrolls sideways on its own, and nothing overflows the
66
+ viewport at 360px. Wide things — tables, code, diagrams — scroll inside their
67
+ own container, never taking the body with them.
68
+
69
+ ## Specificity
70
+
71
+ Section-level and element-level selectors cancelling each other out is the most
72
+ common way generated CSS goes wrong, usually on the padding between sections.
73
+ Keep one owner per property: if `.section` sets the vertical rhythm, a `.cta`
74
+ inside it does not also set it.
75
+
76
+ ## Scroll behaviour
77
+
78
+ Do not put `scroll-behavior: smooth` on `html`. A long smooth scroll past six
79
+ sections reads as a broken page. Handle anchors in script: jump instantly when
80
+ the target is more than two viewports away, glide when it is near, honour
81
+ `prefers-reduced-motion`, and move focus to the target so the keyboard follows
82
+ the eye.
83
+
84
+ `scroll-padding-top` must clear every fixed bar stacked above the content, per
85
+ viewport. An anchor that lands under a sticky header is the most-reported bug
86
+ that nobody can describe.
@@ -0,0 +1,127 @@
1
+ # The work, shown
2
+
3
+ `src/components/gallery/Gallery.astro`, recipes in `galleries.ts` beside it.
4
+
5
+ A body of work is the strongest thing most of these businesses have, and it is
6
+ also the thing most likely to be shown badly: eight hundred posts turned into
7
+ nine cropped squares, or thirty-seven photographs laid end to end until the
8
+ gallery *is* the page.
9
+
10
+ **The arrangement is decided by the material, not by taste.** Two questions
11
+ settle it — how much work there is, and whether the work has an order.
12
+
13
+ | `arrangement` | What it is | Pieces | For |
14
+ | --- | --- | --- | --- |
15
+ | `ragged` | Still columns, every picture at its own proportions. | 6–24 | The default, and the one to argue *against* rather than for. A calf is not the shape a forearm is, and a grid of squares takes the tattoo out of half of them. |
16
+ | `wall` | Columns drifting in opposite directions inside a window about two photographs tall. | 18–200 | Volume as the argument. The only one that moves — see below. |
17
+ | `contact` | Uniform small cells, dense, many at once. | 24–200 | Volume again, said by fitting eighty things on a screen rather than by moving. Wants work that survives being small: silhouettes, not fine line. |
18
+ | `strip` | One row, snapped, moved by the visitor. | 3–20 | Work that has an *order* — a process, a before and an after, a series. |
19
+ | `spread` | A few pieces, each given the full width. | 3–10 | Six outstanding photographs rather than sixty adequate ones, and the honest answer when the feed has been picked over and there is not much. |
20
+ | `index` | Rows: a small frame and the words beside it. | 4–30 | Where what the work *is* — where on the body, how long, what it covered — matters as much as how it looks. It refuses to build without captions, because an index with nothing to read is a worse `ragged`. |
21
+
22
+ The count is checked against the range and warns rather than fails: a `spread`
23
+ of twelve may be deliberate, and a warning in front of whoever chose it, while
24
+ they can still change it, is the whole job.
25
+
26
+ ## Deal, do not slice
27
+
28
+ Pieces go into columns by position — first to column one, second to column two —
29
+ never in slices. Slicing a date-ordered list puts this year in one column and
30
+ three years ago in another, so a visitor reading down one column sees one period
31
+ of the work. Dealing makes every column a fair sample, which is also what makes
32
+ it safe to drop the third column on a phone: what goes is a third of the wall
33
+ and not a period of their work.
34
+
35
+ ## The one that moves
36
+
37
+ `wall` is the exception to the kit's Motion rule, and it is bounded rather than
38
+ argued. A wall of photographs is *looked at* rather than read, which is what
39
+ makes an exception arguable at all; three conditions make it defensible, and all
40
+ three are in the component rather than in somebody's memory:
41
+
42
+ - **it stops on hover and on keyboard focus** — still whenever anybody is
43
+ actually looking at one frame;
44
+ - **reduced motion gets the wall laid out still and in full** — the window opens,
45
+ the animation never starts, and the duplicate pass is not shown;
46
+ - **nothing readable is inside it** and nothing is positioned against it.
47
+
48
+ The loop is seamless because each column is rendered twice and travels exactly
49
+ half its own height: the halfway point is pixel-identical to the start. That is
50
+ the one place a percentage is the right unit — the distance is a property of the
51
+ track, not of the screen.
52
+
53
+ `--gallery-cycle` is long on purpose. A wall that completes in thirty seconds is
54
+ a wall somebody is watching instead of reading the page; a hundred and fifty is
55
+ a surface that is *alive* rather than one that is *playing*.
56
+
57
+ **It declares itself.** The root carries `data-motion="gallery"`, and `fl-check`
58
+ counts motion regions rather than animated elements — so three drifting columns
59
+ are one decision. Two regions on a page is the ceiling: a hero that moves and a
60
+ wall that drifts. A third is a demo.
61
+
62
+ ## Fed from content, which is how the owner keeps it
63
+
64
+ A gallery hard-coded from `src/assets` is a gallery only we can change. Declare
65
+ the field and pass the content, and it is theirs:
66
+
67
+ ```ts
68
+ // src/content/blocks.ts — the declaration
69
+ { key: 'work', label: 'Роботи', kind: 'media', multiple: true, max: 120,
70
+ client_editable: true, hint: 'Порядок тут — порядок на сторінці.' }
71
+ ```
72
+
73
+ ```astro
74
+ ---
75
+ import { block, gallery } from '../copy/uk';
76
+ const plates = block('plates');
77
+ ---
78
+ <Gallery arrangement="wall" items={gallery(plates.of, plates.at, 'work')} />
79
+ ```
80
+
81
+ `gallery()` is the seam and does three things at once: it reads the field, it
82
+ keeps the document's order, and **it gives every picture its `data-jtk-path`**.
83
+ That last one is not optional — without it the owner sees their work and cannot
84
+ touch it, and `jtk catalogue` fails on exactly that. A gallery is the longest
85
+ list on the site to have to fix afterwards.
86
+
87
+ **A picture is either the repository's or the owner's, and `<Shot>` resolves
88
+ both.** An item's `src` is a slot name — `work-dragon`, a file in `src/assets` —
89
+ or a key like `media/<site>/<hash>.jpg` that somebody uploaded in the admin. The
90
+ build downloads the keys into `src/assets/` before it runs, so both go through
91
+ `astro:assets` and come out with variants and a `srcset`. Nothing on the page
92
+ has to know which it was given, which is the point: a photograph the owner
93
+ swapped in lands in exactly the frame the developer's was in.
94
+
95
+ That is also what lets a bespoke site be pushed with its work already in it. The
96
+ first content document names the slots the repository ships; the owner replaces
97
+ them one at a time, or not at all.
98
+
99
+ ### The clips
100
+
101
+ `<Clip>` rather than `<Gallery>`: five loops in a pile, one behind a heading, a
102
+ strip of four — the arrangement is the design's, and a clip has its own rules
103
+ (autoplay, no audio track, a poster that is its own first frame, reduced motion
104
+ stopped on frame one). All of them live in the component.
105
+
106
+ ```astro
107
+ {gallery(of, at, 'clips').map((loop) => (
108
+ <Clip src={loop.name} poster={loop.poster} path={loop.path} />
109
+ ))}
110
+ ```
111
+
112
+ **One label for a set, not one per clip.** Five loops are visually one thing;
113
+ five labels read out in a row are worse than one sentence. Put `role="img"` and
114
+ an `aria-label` on the container and leave the clips unlabelled.
115
+
116
+ ## What it does not do
117
+
118
+ No arrangement crops a photograph to a shape it is not, and none of them puts a
119
+ caption inside a moving surface. `contact` is the only one that squares its
120
+ cells, and it says so — it is a contact sheet, and that is what one looks like.
121
+
122
+ The pictures come through `<Shot>`, so everything true there is true here: the
123
+ treatment, the alt text, the placeholder for a frame that has not arrived, and
124
+ the refusal to stretch anything. The duplicate pass a `wall` renders is marked
125
+ `decorative`, because the same photograph described twice is worse for a screen
126
+ reader than described once.
127
+
@@ -0,0 +1,189 @@
1
+ # The hero's moving ground
2
+
3
+ A landing page gets about one second to look like somebody built it. A still
4
+ gradient does not buy that second; a field of colour that is visibly *alive*
5
+ does, and it costs one canvas and no dependencies.
6
+
7
+ `src/components/motion/HeroField.astro` is the component — the canvas, the
8
+ palette, the sizing budget and every path back to the still ground. `fields.ts`
9
+ beside it is what each field actually looks like. Both ship in the scaffold and
10
+ are used only where the page chooses them; delete the directory if it never
11
+ imported one.
12
+
13
+ ```astro
14
+ <HeroField
15
+ field="drift"
16
+ ground="#fbf7f2"
17
+ colors={['#c8632f', '#7a3a18', '#f0e4d8', '#241a12']}
18
+ scrim
19
+ >
20
+ <div class="hero__inner">…</div>
21
+ </HeroField>
22
+ ```
23
+
24
+ `colors` and `ground` are required props. That is the point, not an oversight —
25
+ see below.
26
+
27
+ ## The set
28
+
29
+ Ten fields, and they are not ten skins of one look — each is here because it
30
+ does something the others cannot. Pick from the design.
31
+
32
+ | `field` | What it is | Wants |
33
+ | --- | --- | --- |
34
+ | `drift` | Four lamps of colour on slow orbits through a warped noise field. Calm, wide, plenty of quiet space. | A light or mid ground, a long headline, one photograph beside it. |
35
+ | `aurora` | Three layers of simplex noise riding each other, a glow at the centre, a vignette. Denser, more dramatic, obviously moving. | A dark ground, short copy, nothing else competing. |
36
+ | `plasma` | Four sine fields summed and read through the palette: bands, rings and interference. The only field with visible structure. | Two colours far apart, and a hero that can carry something graphic. |
37
+ | `glow` | One bright filament of light in a dark room — most of the frame stays at the ground colour, which is what makes the lit part read as light rather than as paint. | A dark ground, and a palette with one colour much brighter than the rest. |
38
+ | `flow` | A flow field: noise decides a direction at every point and a second noise is read along it. Marbled, like ink pulled through water. | Two or three colours close in tone, and a hero with room — it is busy. |
39
+ | `silk` | The coordinate fed back through a cosine of itself four times, folding the plane into creases. The softest field here. | Anything. It is the one that survives a lot of text over it. |
40
+ | `smoke` | Two rounds of domain warping: a handful of large plumes folding over each other. The field with an inside. | A palette with a real dark in it — the plumes need somewhere to be deep — and a hero tall enough to show one whole plume. |
41
+ | `riso` | A wave through the palette, dithered by a coarse quantised noise *before* the lookup: neighbouring bands of flat colour with a ragged, printed border. The one field that is not a smooth gradient. | Two or three colours far enough apart to make bands, and a design that can carry something graphic. It is the loudest here after `plasma`. |
42
+ | `veil` | A curtain hanging from the horizontal, thinning as it rises, the ground showing through where it is not. | A dark ground worth showing through, and copy that sits in the quiet upper part of the frame. |
43
+ | `ribbons` | Stacked sine curves drawn as lines, not as colour. Canvas 2D, so it is also the field that survives where WebGL does not. | A dark ground, thin type, a design that is already linear. |
44
+
45
+ Two pairs in that table are close, and knowing which is which is the difference
46
+ between choosing and picking. **`smoke` and `flow`** are both warped noise: flow
47
+ is a flow field, even everywhere, and it reads as marbling; smoke is warped
48
+ twice at a wider scale, so it has a few large plumes and a dark core, and it
49
+ reads as volume. **`veil` and `aurora`** are both weather: aurora is centred —
50
+ a glow in the middle, a vignette around it, everything pointing inward — while
51
+ veil is anchored to the horizontal and thins upward, which leaves the top of the
52
+ frame quiet for the copy. If a design cannot say which of a pair it wants, it
53
+ does not want either yet.
54
+
55
+ `ribbons` takes an optional `pointer`, which lets the stack lean toward the
56
+ cursor with a lag. It is off by default and it should usually stay off: a
57
+ background that follows the mouse is a background being looked at instead of the
58
+ words. Where a page's whole idea is the interaction, turn it on.
59
+
60
+ The set is meant to grow. Adding one is a fragment source plus a row in
61
+ `FRAGMENT` — the host, the fallback and every degradation path are already
62
+ written. Two things a candidate has to clear:
63
+
64
+ - **A different job.** Another variation on soft coloured blobs is not a sixth
65
+ option, it is `drift` with different constants. Ask what a design could do
66
+ with it that it could not do with the five.
67
+ - **No dependency.** A field that arrives as an npm package is a package in
68
+ every client's bundle, a version to keep, and a look somebody else controls.
69
+ The shaders here are a few hundred lines of GLSL we can read and re-tune; that
70
+ is the whole reason they can be repainted in a client's colours.
71
+
72
+ A field arriving from elsewhere usually needs both of those checked. Most
73
+ published hero shaders are a look plus a fixed palette, and the palette is the
74
+ part that has to go: what gets adopted here is the *behaviour* — how it moves,
75
+ what it does with a colour ramp — rewired onto `u_colors` and `u_bg` so the
76
+ client's palette drives it.
77
+
78
+ `smoke`, `riso` and `veil` arrived exactly that way, out of the 21st.dev shader
79
+ builder. What was kept is three `shade()` functions; what was dropped is three
80
+ React components, three WebGL hosts we already had, and three fixed palettes —
81
+ one of which was a purple-and-cyan gradient that would have shipped on somebody's
82
+ site. Each was then retuned against the field it was closest to, because a
83
+ shader that is *nearly* one we already have is worse than not adding it.
84
+
85
+ A canvas is also not the only way to move a hero. A CSS-only ground — two
86
+ `radial-gradient`s on long `@keyframes`, a conic sweep, a grain overlay — costs
87
+ no canvas and no JavaScript, and on a page whose hero is mostly a photograph it
88
+ is often the better answer. Reach for a field when the motion is the point.
89
+
90
+ And a field is not the only ground. [surface.md](surface.md) is the still
91
+ catalogue — six grounds, painted in the same tokens, with no canvas and no
92
+ script — for the rest of the page and for the heroes that should not move. A
93
+ page with `field: ""` and no ground is a page nobody finished; the two documents
94
+ are read together.
95
+
96
+ ## It is in their colours, or it is somebody else's page
97
+
98
+ **This is the rule the whole component is built around, and it is enforced
99
+ rather than advised.** `colors` and `ground` have no defaults: the page does not
100
+ type-check until four colours and a ground have been chosen, and `<HeroField>`
101
+ throws during the build if fewer than four arrive. `npm run check` at zero is a
102
+ gate every stage already passes through, so a field in nobody's colours cannot
103
+ reach a client.
104
+
105
+ The reason is not tidiness. A shader in its gallery palette — the purple-and-cyan
106
+ one, the blue one this kit would have defaulted to — is the single loudest tell
107
+ that a page was generated rather than designed, and a default is how it happens
108
+ every time: it looks fine, so nobody goes back to change it.
109
+
110
+ Where the colours come from, in order:
111
+
112
+ 1. **Their photographs.** `PORTRAIT.md` and `BRIEF.md` both have a *Colour and
113
+ material* section written off the client's own feed — the wood of the room,
114
+ the oil, the dough, the polish. That section exists for this.
115
+ 2. **The variant's tokens**, when those tokens were themselves taken from the
116
+ photographs. `colors={['var(--accent)', …]}` is right in that case and only
117
+ in that case — and it keeps the field following the studio bar's live accent
118
+ picker, since the values are read as CSS custom properties.
119
+ 3. **Nothing else.** Not the shader's original preset, not a palette that suited
120
+ the last project, not something that looks good in isolation.
121
+
122
+ What makes a palette work here rather than merely be theirs: one dominant brand
123
+ colour, one lighter relative of it, one colour far enough away to give the field
124
+ somewhere to travel, and a ground the text can actually sit on. Four tokens that
125
+ all sit near each other produce a field nobody notices.
126
+
127
+ Any CSS colour works, including `color-mix()`: the values are normalised through
128
+ a 2D context rather than parsed by hand.
129
+
130
+ ## Every way it fails ends in the same place
131
+
132
+ No JavaScript, no WebGL, a driver that refuses, `prefers-reduced-motion`, a lost
133
+ context, a tab in the background, the hero scrolled off screen — all of them end
134
+ at the CSS ground painted from the same four colours, and the page is not
135
+ missing anything. **Nothing readable is ever inside the canvas**, which is what
136
+ makes that true.
137
+
138
+ Three consequences worth stating, because each one has cost an afternoon
139
+ somewhere:
140
+
141
+ - **The still ground is a design, not a loading state.** It is what a phone with
142
+ WebGL disabled shows, permanently. Look at it once with JavaScript off before
143
+ the stage ends.
144
+ - **Reduced motion means the finished picture, not a missing one.** The field is
145
+ painted once and then left alone — not hidden, not blank.
146
+ - **The canvas is decorative**, `aria-hidden`, and never the LCP element. Text
147
+ and the call to action paint from HTML and CSS with no dependency on the
148
+ shader.
149
+
150
+ ## Text on a moving ground
151
+
152
+ Contrast that holds in the frame you are looking at does not hold in the frame
153
+ thirty seconds later — the field moves, and a heading that reads over a light
154
+ lamp disappears over a dark one. So:
155
+
156
+ - **`scrim` whenever text sits on the field.** It lays a gradient of the ground
157
+ colour between the canvas and the content, which costs a little of the effect
158
+ and buys legibility in every frame.
159
+ - **Check the worst frame, not the first.** Freeze the field at a few different
160
+ times and read the heading in each.
161
+ - **Or keep the text off it** — the field behind, the copy in a panel above it.
162
+ That is the reliable answer for long copy, and the only one for small text.
163
+
164
+ ## A photograph and a field, in the same hero
165
+
166
+ They can share a hero, and they must not fight over it. The field is the ground;
167
+ the photograph sits on it in its own frame with its own edge, and the field goes
168
+ quiet where the picture lands — that is what `scrim` and a calmer `field="drift"`
169
+ are for. Two loud things in one hero read as neither.
170
+
171
+ For a business run by one person the photograph is usually the more valuable
172
+ half of that pair — see [photos.md](photos.md). A field is the right hero on its
173
+ own when there is no usable photograph of the person or the work, which on the
174
+ outbound track is common.
175
+
176
+ ## What it costs
177
+
178
+ - **Fill rate.** A full-screen fragment shader is the whole viewport, every
179
+ frame. The component caps device pixel ratio at 2 and the canvas at ~2M
180
+ pixels, which is what keeps a 3× phone from spending its battery on a
181
+ background nobody is looking at. `ribbons` spends CPU instead of GPU — about
182
+ ten thousand line segments a frame at desktop width — which is cheap enough
183
+ and is the trade that lets it run without WebGL at all.
184
+ - **Nothing while it is off screen.** An `IntersectionObserver` and
185
+ `visibilitychange` stop the loop; the elapsed clock stops with it, so scrolling
186
+ back does not jump the animation forward.
187
+ - **About 4KB of script**, no dependency, no framework. If a page carries a
188
+ framework for this, something has gone wrong.
189
+