@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,459 @@
1
+ # The catalogue
2
+
3
+ `jtk/catalogue.json` is what lets a site built here be handed to its owner.
4
+
5
+ The studio's admin edits a site by tapping the text on it. It can do that for a
6
+ site built any way at all — any markup, any components, any layout — because it
7
+ reads the catalogue rather than knowing anything about the site. **That is what
8
+ makes bespoke and editable the same sentence rather than a trade.**
9
+
10
+ ```
11
+ src/ the design. Yours. Anything at all.
12
+ jtk/ the values. The owner's, edited in the admin.
13
+ catalogue which values there are, and what each one is
14
+ ```
15
+
16
+ ## Declare, do not write
17
+
18
+ Nobody edits `catalogue.json`. It is emitted from `src/content/blocks.ts`, which
19
+ sits beside the components, is type-checked, and is where the decision actually
20
+ belongs:
21
+
22
+ ```bash
23
+ npx @jtakeit/astro catalogue
24
+ ```
25
+
26
+ **A field declared there can be edited by the owner. A field left out is part of
27
+ the design.** Both are right answers — a headline is content, the way a section
28
+ is laid out is not — and the choice is per field rather than something a
29
+ framework makes for you.
30
+
31
+ Then it checks itself against the built page, in both directions:
32
+
33
+ | | what it means |
34
+ | --- | --- |
35
+ | declared, not rendered | a control in the admin that edits nothing |
36
+ | rendered, not declared | text the owner can see and cannot touch |
37
+
38
+ The second is the one that hides, which is why `annotation-lint.mjs` catches it
39
+ again at build time. Between them a catalogue cannot drift from the page.
40
+
41
+ ## The codes, and who is right about them
42
+
43
+ Everything `jtk catalogue` refuses is named:
44
+
45
+ ```
46
+ ✗ JTK_E_MEDIA_INVALID hero.clip: accept: 'video' without multiple — …
47
+ ✗ FL_LOCAL src/Nav.astro: "/preise/" is written from the root — …
48
+ ```
49
+
50
+ A `JTK_E_…` code is the platform's, not this skill's. What it means and what to
51
+ do about it are one row in the admin's generated `docs/reference/errors.md`, and
52
+ that page is the one to read — it is written from the validator itself, and
53
+ anything restated here would be a second wording of a rule that has one. The
54
+ same code comes back from the admin when a site is attached, so the two answers
55
+ about one file are legibly the same answer. `FL_LOCAL` is the handful of checks
56
+ that are ours alone: whether the build survives being served under the preview's
57
+ `/p/<slug>/` prefix, which the catalogue format has no opinion about.
58
+
59
+ **The judge is the admin, not this checker.** The rules above are implemented
60
+ twice — in Go on the server, and in `lib/catalogue.mjs` so the answer arrives with
61
+ a line number before anything is deployed — and two implementations of one
62
+ contract drift silently. So the local one sits the other's exam:
63
+
64
+ ```bash
65
+ npx @jtakeit/astro catalogue --judge # needs JTK_API, JTK_TOKEN
66
+ ```
67
+
68
+ It posts the emitted catalogue to the real validator and reports every code the
69
+ two disagree about. Run it on client projects: those runs are the only thing
70
+ keeping the copy true, and a divergence is a bug in the script rather than in the
71
+ site.
72
+
73
+ ## What a field is
74
+
75
+ ```ts
76
+ { key: 'title', label: 'Заголовок', kind: 'text', max: 80,
77
+ required: true, client_editable: true, seo_sensitive: true }
78
+ ```
79
+
80
+ - **`key`** is permanent. It is the key in the content document, so renaming one
81
+ loses whatever the owner had written in it.
82
+ - **`label` is the sentence itself**, in the site's language. The admin's own
83
+ blocks label their fields through a locale file; a bespoke block has no locale
84
+ file, so the text goes here and the admin shows it as written.
85
+ - **`kind`** decides the control and the validation: `text`, `textarea`,
86
+ `richtext_lite`, `number`, `money`, `duration`, `time_of_day`, `tel`, `url`,
87
+ `email`, `date`, `select`, `bool`, `media`, `list`.
88
+ - **`money`** is a whole number of **minor units** — `25000` is 250.00. Never a
89
+ decimal: money that is a float is money that is wrong the day somebody sums
90
+ a column, and minor units are what Stripe is handed later. The currency is
91
+ the site's and is not part of the value — the template writes the symbol
92
+ beside the amount it renders.
93
+ - **`duration`** is a whole number of **minutes** — `90` is an hour and a half.
94
+ Minutes because that is the unit slot arithmetic works in, so nothing
95
+ converts on the way. The panel reads it back as "1 год 30 хв".
96
+ - **`time_of_day`** is whole minutes from **local midnight** — `540` is nine in
97
+ the morning, and there is no such value as 1440. No zone travels with it:
98
+ the zone is the site's, declared once.
99
+ - **`client_editable`** separates the owner from the studio. A headline is
100
+ theirs; a button's destination points at a section they did not build.
101
+ - **`no_tap_target`** is for a value that never appears as its own element — a
102
+ message a script writes later, a string that lands in an attribute, a label
103
+ only a screen reader hears. It is edited in the panel beside the preview, and
104
+ the checks must not look for something to tap.
105
+ - **`seo_sensitive`** shows a warning that changing it moves the page in search
106
+ results.
107
+
108
+ `list` holds rows: `of` is the fields of one row, and a list inside a list is
109
+ not something the admin has a control for.
110
+
111
+ ## Attaching the site
112
+
113
+ Once `jtk catalogue` passes:
114
+
115
+ 1. Push the repository.
116
+ 2. In the admin, create the site and paste the repository's URL.
117
+ 3. Import — the admin reads the ref into the draft.
118
+
119
+ The import refuses a ref with no catalogue, which is the whole reason this file
120
+ exists rather than being optional.
121
+
122
+ ## What the owner may break, and what they may not
123
+
124
+ Nothing in `src/` is reachable from the admin. An owner can empty a headline and
125
+ they cannot move a section, change a colour or delete a component — those are
126
+ the design, and the design is a commit.
127
+
128
+ That is also the answer to "what happens when both of us are working": the
129
+ developer writes `src/`, the admin writes `jtk/`, and they never touch the
130
+ same file.
131
+
132
+ ## Every sentence a visitor reads is content, unless it is machinery
133
+
134
+ The same rule as photographs, and it catches more sites: a string written into
135
+ a component is a string only we can change.
136
+
137
+ **Content** is anything said in the business's voice — the wordmark and the line
138
+ under it, the footer's note about how to book, and, the one that gets missed
139
+ every time, **what the form asks for**. "The idea, where it goes on the body,
140
+ roughly how big" is a tattooist deciding what she needs before she can answer;
141
+ the next business needs a date, a headcount, a registration number. A visitor
142
+ reads it and the owner is the only person who knows what it should say.
143
+
144
+ **Machinery** is the short list around it: "Sending…", "Sent.", the sentence a
145
+ browser shows for an empty field, the skip link. There is nothing an owner wants
146
+ to say in those, and the server keeps its own copy of the refusals anyway.
147
+
148
+ The scaffold's form now declares its questions — `name_label`, `contact_label`,
149
+ `message_label`, `required_note` on `cta_banner` — and its machinery stays in
150
+ the copy adapter.
151
+
152
+ **The navigation is content too**, and it is the one most often left out. The
153
+ labels in the header and the footer are the words a customer navigates by —
154
+ «Preise», «Kurse», «Galerie» — and a business renames them: a price list becomes
155
+ a menu, courses become workshops. The *addresses* stay the design's, because a
156
+ route the owner could retype is a route they could break; so declare the labels
157
+ and leave the hrefs out of the catalogue, or declare them `client_editable:
158
+ false`. A nav list in `src/data/nav.ts` is fine as the shape; the words in it
159
+ come from the shared document.
160
+
161
+ ### Say which, in the repository, for every sentence
162
+
163
+ The rule above is a judgement, and a judgement that is not written down is not a
164
+ decision. Every visible sentence on a built page is one of two things:
165
+
166
+ | | how it is written | who changes it |
167
+ | --- | --- | --- |
168
+ | the owner's | declared in the catalogue, annotated `data-jtk-path` | they do, by tapping it |
169
+ | the design's | marked `data-jtk-fixed` | we do, in a commit |
170
+
171
+ `data-jtk-fixed` is a boolean attribute and it silences everything inside the
172
+ element it is on, so one attribute covers an ornament made of six spans:
173
+
174
+ ```astro
175
+ <a class="skip" href="#main" data-jtk-fixed>Zum Inhalt springen</a>
176
+
177
+ <div class="trap" aria-hidden="true" data-jtk-fixed>
178
+ <label for="lead-website">Website</label>
179
+ <input id="lead-website" name="website" type="text" tabindex="-1" />
180
+ </div>
181
+ ```
182
+
183
+ It is for machinery and for ornament: a skip link, a honeypot, a decorative
184
+ caption, a counter drawn by the design. It is not a way to make a decision go
185
+ away — a nav label marked `data-jtk-fixed` is a nav label the owner cannot change,
186
+ and that has to be a thing somebody chose rather than a thing somebody skipped.
187
+
188
+ **`jtk catalogue` fails on a sentence that is neither.** It used to list them and
189
+ pass, which made the decision optional — and optional is how a site shipped with
190
+ twenty-six strings belonging to nobody: the navigation, the footer and the
191
+ labels on the form, with a line in the handover saying the boundary had been
192
+ drawn deliberately. Nothing had been drawn: the strings were simply written into
193
+ components, and the owner found out by wanting to change one.
194
+
195
+ ### The wordmark and the footer
196
+
197
+ They are content and they sit outside `<main>`, which is the only thing that
198
+ makes them awkward: **an annotation path is `blocks[i].field`, so a tappable
199
+ string has to be a field of a block.** Nothing requires that block to render
200
+ inside the article — where a block appears is the design's business, and the
201
+ lint looks for its path anywhere on the page.
202
+
203
+ So a site that wants them editable declares one:
204
+
205
+ ```ts
206
+ {
207
+ type: 'signature',
208
+ v: 1,
209
+ label: 'Підпис сайту',
210
+ fields: [
211
+ { key: 'wordmark', label: 'Назва', kind: 'text', max: 60, client_editable: true },
212
+ { key: 'role', label: 'Хто це і де', kind: 'text', max: 90, client_editable: true },
213
+ { key: 'footnote', label: 'Рядок у підвалі', kind: 'text', max: 140, client_editable: true },
214
+ ],
215
+ }
216
+ ```
217
+
218
+ and the header and the footer read it through `block('signature')`. The same
219
+ field can be annotated in both places — a patch reaches every element carrying
220
+ the path, so a wordmark in the corner and the same wordmark in the foot stay one
221
+ value.
222
+
223
+ Two things not to do. Do not leave them in a `SITE` constant because they are
224
+ "furniture": the owner's own name is not furniture, and neither is the sentence
225
+ telling somebody how to book. And do not reach for `business_facts` — it has
226
+ `name` and `city`, it is edited in a panel rather than by tapping, and there is
227
+ no annotation path shape for it.
228
+
229
+ **A separator between two values is CSS, never a text node.** The footer joins
230
+ the wordmark and the role on one line, and written the obvious way
231
+
232
+ ```astro
233
+ <span data-jtk-path={…}>{sign.name}</span> — <span data-jtk-path={…}>{sign.role}</span>
234
+ ```
235
+
236
+ the dash and the spaces around it belong to neither field. Nothing on the page
237
+ can edit them, and the owner sees two outlined boxes with a dead gap between
238
+ them and reasonably asks what the gap is. Put it where it belongs:
239
+
240
+ ```css
241
+ .foot__role::before { content: ' — '; }
242
+ ```
243
+
244
+ Now nothing between the two is unowned, the line reads as one, and a tap on the
245
+ dash opens the role — which is the field it is attached to.
246
+
247
+ The same trap, one level up: a value repeated in two places is one field
248
+ annotated twice, not two fields. A patch reaches every element carrying the
249
+ path, so a wordmark in the corner and the same wordmark in the foot stay one
250
+ value however many times the design prints it.
251
+
252
+ ## Media: a wall of work, and a set of clips
253
+
254
+ Two shapes turn up on nearly every site now. Both are `media` fields, and
255
+ between them they add two words to the catalogue's vocabulary.
256
+
257
+ **A gallery is one field, not thirty-seven.**
258
+
259
+ ```ts
260
+ {
261
+ key: 'work',
262
+ label: 'Роботи',
263
+ kind: 'media',
264
+ multiple: true,
265
+ max: 120,
266
+ client_editable: true,
267
+ hint: 'Порядок тут — порядок на сторінці.',
268
+ }
269
+ ```
270
+
271
+ Declared as a `list` of rows it becomes a repeater — add a row, open it, choose
272
+ a file, close it, thirty-seven times — and that is the wrong shape for what a
273
+ gallery is: a collection you drop pictures into and take pictures out of. One
274
+ field, an ordered array of items, each carrying its own `alt`, because a
275
+ photograph with no alt text is announced as a file name.
276
+
277
+ A `list` of rows is still right where every piece has **words** beside it and
278
+ the words are the point — the `index` arrangement in [gallery.md](gallery.md).
279
+ The question is whether the owner is editing entries or filling a bag.
280
+
281
+ **The arrangement is not in the catalogue and must not be.** How the work is
282
+ shown — drifting columns, a dense contact sheet, a few large pieces — is a
283
+ design decision taken from how much work there is, and it is not content.
284
+
285
+ **A set of clips** is the same field with what it holds declared:
286
+
287
+ ```ts
288
+ {
289
+ key: 'clips',
290
+ label: 'Відео процесу',
291
+ kind: 'media',
292
+ accept: 'video',
293
+ multiple: true,
294
+ max: 6,
295
+ client_editable: true,
296
+ }
297
+ ```
298
+
299
+ `accept: 'video'` is there because what the page does with a clip is different
300
+ from what it does with a photograph: it autoplays it, loops it, and falls back
301
+ to a poster frame every time autoplay does not happen. The clips and their posters are cut from the client's own video with whatever tooling you have; `<Clip>` plays them.
302
+
303
+ Both words are for `media` fields and nothing else, and `jtk catalogue` says so.
304
+
305
+ **A clip is always `multiple`, even when there is one of it.** A clip is stored
306
+ with its poster, and a plain media field is one key with nowhere to keep one, so
307
+ one clip is `multiple: true, max: 1`. It reads oddly for a moment and it means
308
+ every video on every site has the same value shape.
309
+
310
+ ### The frame, and what must survive it
311
+
312
+ A client uploads a photograph in whatever shape their camera gave it and the
313
+ page has a slot of its own. Declare the slot:
314
+
315
+ ```jsonc
316
+ { key: 'work', kind: 'media', multiple: true, ratio: '3:2', client_editable: true }
317
+ ```
318
+
319
+ **The frame is yours and the subject is theirs.** How a page is proportioned is
320
+ the design's answer, so `ratio` is declared here and there is no handle in the
321
+ admin to drag — two pages of one site coming out differently proportioned is
322
+ the thing this prevents. What the owner *does* say is which part of their
323
+ picture must survive the crop, because that is the one thing the design cannot
324
+ know: they press the subject and the frame moves to it.
325
+
326
+ There is no stretching. A photograph with its geometry changed is a spoiled
327
+ photograph, not a laid-out one.
328
+
329
+ A field with a `ratio` gives each item a **`focus`** — the CSS value itself,
330
+ `"50% 25%"`, so it drops straight into `object-position` with nothing to
331
+ convert. `Shot.astro` already takes both:
332
+
333
+ ```astro
334
+ <Shot name={item.src} ratio="3 / 2" fit="cover" focus={item.focus} … />
335
+ ```
336
+
337
+ **A framed field is always `multiple`**, for the same reason a clip is: the
338
+ focus is stored beside the picture, and a plain media field is one key with
339
+ nowhere to put it. One framed picture is `multiple: true, max: 1`.
340
+
341
+ ### What one item is
342
+
343
+ ```jsonc
344
+ "work": [ { "src": "media/<site>/<hash>.jpg", "alt": "Дракон, передпліччя" } ]
345
+ "framed":[ { "src": "media/<site>/<hash>.jpg", "alt": "Дракон", "focus": "50% 25%" } ]
346
+ "clips": [ { "src": "media/<site>/<hash>.mp4",
347
+ "poster": "media/<site>/<hash>.jpg", "alt": "Стенсіл" } ]
348
+ ```
349
+
350
+ `src`, `alt`, `poster`, and nothing else. **The three keys are not declared per
351
+ site** — the admin synthesises them from the field, so a site that named them
352
+ `file` and `caption` would be a site the editor cannot follow. `src` is a key in
353
+ the studio's bucket, never a URL; `alt` is optional to store and required to be
354
+ worth reading, because a photograph without it is announced as a file name; a
355
+ clip's `poster` is generated from its own first frame when the owner uploads it,
356
+ the same frame the clip's poster is.
357
+
358
+ Order is position. There is no id and nothing to sort by: the array is the page.
359
+
360
+ ### How a component reads one
361
+
362
+ The keys are downloaded into `src/assets/<key>` before the build, so a gallery
363
+ goes through `astro:assets` exactly as `src/assets` photographs do — variants,
364
+ `srcset`, the lot. What a component must not do is address `src` as a URL: that
365
+ is our bucket's address and it changes.
366
+
367
+ ### The annotation
368
+
369
+ ```
370
+ blocks[3].work[7].src
371
+ ```
372
+
373
+ One picture is one tap target, and **the container is not one** — tapping a wall
374
+ of thirty-seven and being offered "the wall" to edit is not an edit anybody
375
+ means to make. Tapping any picture opens the whole gallery in the admin with
376
+ that one selected, which is where add, remove, reorder and re-caption live.
377
+
378
+ `alt` and `poster` carry no annotation and are not looked for: neither of them
379
+ appears on the page as itself. Annotating them anyway is allowed and does
380
+ nothing.
381
+
382
+ ### What is served from where
383
+
384
+ `/media/…` is the studio's prefix on the hosting edge: an owner's uploads are
385
+ served from it. A file the site ships under that path is not reachable, which is
386
+ why clips go in `public/clips/`. Anything else in `public/` is the site's
387
+ own and is served as it always was.
388
+
389
+ `public/_headers` is a Cloudflare Pages convention the platform's edge does not
390
+ read — headers are the platform's, and a site cannot set its own. The scaffold
391
+ ships none.
392
+
393
+ ## Turning a wall that was built into a wall that is owned
394
+
395
+ A site built before galleries were content has its work in the components: a
396
+ list of slot names in a page, or a `PHOTOS` registry, and every picture a
397
+ decision only we can change. Four steps, in this order, and the site is
398
+ attachable at the end of them.
399
+
400
+ **0. Bring the components that changed.** A site built before this has its own
401
+ copies, and the mechanism lives in six files:
402
+
403
+ ```
404
+ src/lib/uploads.ts new — resolves a content key to a local file
405
+ src/lib/photos.ts photo() now answers for keys as well as slots
406
+ src/components/Shot.astro takes `path`, and puts it on the <img>
407
+ src/components/Clip.astro new — one silent looping clip, done properly
408
+ src/components/gallery/ Gallery.astro + galleries.ts: path onto the picture
409
+ src/copy/<locale>.ts gallery(), the seam
410
+ ```
411
+
412
+ Copy them over the site's own, then reconcile: everything else in those files is
413
+ the kit's and unchanged, so a diff shows only what this added.
414
+
415
+ **1. Declare the fields.** In `src/content/blocks.ts`, on the block the work
416
+ belongs to — not a new block. The type name is permanent, so it is the one this
417
+ page already uses.
418
+
419
+ ```ts
420
+ { key: 'work', label: 'Роботи', kind: 'media', multiple: true, max: 120, client_editable: true },
421
+ { key: 'clips', label: 'Відео процесу', kind: 'media', accept: 'video', multiple: true, max: 6, client_editable: true },
422
+ ```
423
+
424
+ **2. Move the pictures into the content document**, as items, keeping the order
425
+ the page already had and carrying each one's alt text with it. Slot names, not
426
+ keys: the files stay exactly where they are.
427
+
428
+ ```js
429
+ // One-off, from PHOTOS — thirty-seven of these by hand is thirty-seven chances
430
+ // to lose an alt.
431
+ const items = PHOTOS.filter((p) => p.name.startsWith('work-'))
432
+ .map((p) => ({ src: p.name, alt: p.alt }));
433
+ ```
434
+
435
+ Put them under the block's key in `jtk/content/index.json`. The alt text
436
+ now lives in two places — `photos.ts` and the document — and the document is the
437
+ one the page reads; leave the registry alone, it still describes the files.
438
+
439
+ **3. Render through the seam.** `gallery()` for the pictures, `<Clip>` for the
440
+ loops — [gallery.md](gallery.md) has both. What the page loses is the hard-coded
441
+ list; what it keeps is every visual decision, because the arrangement was never
442
+ content.
443
+
444
+ **4. Check, and push.**
445
+
446
+ ```bash
447
+ npx @jtakeit/astro catalogue # emits, builds, checks both ways
448
+ ```
449
+
450
+ Zero means the admin can be handed this repository: every declared field renders
451
+ with its path, and nothing on the page is text the owner cannot touch.
452
+
453
+ One thing to look at with your own eyes before pushing: **the annotation goes on
454
+ the `<img>`, never on the frame around it.** The admin replaces a picture by
455
+ setting `src` on the element it was told about, and an element that is not an
456
+ image gets its *text* replaced instead — so a `data-jtk-path` on a `<figure>`
457
+ turns the client's photograph into a URL printed where the picture was. `<Shot
458
+ path={...}>` puts it in the right place; a hand-rolled `<figure data-jtk-path>`
459
+ does not.