@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,109 @@
1
+ # The lead form
2
+
3
+ One form, one endpoint: `/api/lead`, which the platform serves beside the site on
4
+ every host it answers on, and under the preview at `/p/<slug>/api/lead`. Where an
5
+ enquiry goes — a Telegram chat, an address, the panel's inbox — is the owner's
6
+ setting in the panel, never the repository's; what the form collects is the
7
+ site's, declared in the catalogue (`collects: enquiry`, and the block's `asks`).
8
+
9
+ Template: `src/components/LeadForm.astro`.
10
+
11
+ ## The band it sits in
12
+
13
+ The form is a component; the band around it is a layout decision, and it is the
14
+ one that goes wrong on a wide screen. A 600px form centred in a 1920px band with
15
+ two thirds of the screen empty on either side is the commonest way a finished
16
+ page still looks unfinished — and it happens to the block the whole page exists
17
+ to deliver somebody to.
18
+
19
+ Either give the form something to stand beside — the contact details, the
20
+ opening line, a photograph of the room, the questions block — or let it use the
21
+ width: labels beside fields, name and contact on one row. `fl-check` fails a form
22
+ band that uses less than 45% of a 1920px screen.
23
+
24
+ ## Shape
25
+
26
+ ```
27
+ src/components/LeadForm.astro <form method="post" action={under('/api/lead')}>
28
+ ```
29
+
30
+ The platform's edge answers `/api/lead` on every host the site is served from,
31
+ and under the preview at `/p/<slug>/api/lead`. That is why the action goes
32
+ through `under()` like every other address the build writes — root-absolute, it
33
+ leaves the preview and lands in a 404 that reads as the form being broken.
34
+ Locally, under `astro dev`, there is nothing behind it and the form answers 404:
35
+ that is expected.
36
+
37
+ ## The form is a form first
38
+
39
+ ```html
40
+ <form method="post" action={under('/api/lead')} novalidate data-form>
41
+ ```
42
+
43
+ A real POST target, so with JavaScript off the browser submits and the function
44
+ answers with a small self-contained HTML page. The script only spares a working
45
+ browser the navigation. `novalidate` because the messages are ours, in the
46
+ client's language, not the browser's in the browser's.
47
+
48
+ Fields: as few as the business actually needs. Name plus one contact channel is
49
+ usually the whole form. Every extra field costs conversions, and a field the
50
+ client never reads costs them for nothing.
51
+
52
+ ## The contract with the platform
53
+
54
+ The form is the site's, and the platform does not know its field names. What
55
+ it holds the form to is the **structure** every enquiry has, and it checks it
56
+ twice — at build, on the page, and at the edge, on every post:
57
+
58
+ | control | what it is | if it is missing |
59
+ | --- | --- | --- |
60
+ | `name` | who is writing | the edge answers `400 {"error":"anonymous"}` |
61
+ | `contact`, or `phone` / `email` | a way back: a number or an address, told apart by the `@` | `400 {"error":"unreachable"}` |
62
+ | `message` | what they said; may be empty | — |
63
+ | `website` | the honeypot; a filled one is answered as sent and dropped | every bot gets through |
64
+
65
+ Every other control is the site's own question — a wished date, a service, a
66
+ number of guests — and is **declared in `asks`** on the block that carries
67
+ `collects: 'enquiry'` in `src/content/blocks.ts`, as an ordinary field with a
68
+ label and a kind. `LeadForm.astro` renders them from that declaration, and the
69
+ same declaration is what lets the owner's inbox, the Telegram message, the
70
+ email and the CSV say "Wished date" rather than `field_3`. The edge keeps
71
+ every extra by its control's name (twenty fields, two kilobytes each), and a
72
+ build for the site's own address refuses a form with a control nobody declared
73
+ (`JTK_E_FORM_FIELD_UNDECLARED`), a form missing one of the four above
74
+ (`JTK_E_FORM_INCOMPLETE`), or a form on a page where no block says it
75
+ collects (`JTK_E_FORM_UNDECLARED`). A preview build warns instead.
76
+
77
+ The other refusals the endpoint names are `too_long` (the name over 120, the
78
+ message over 2000, or the extras over the cap), `too_many` (the rate counter)
79
+ and `not_delivered` (kept, and not yet delivered — the site says to ring).
80
+ The scripted path reads the code from the JSON answer; the unscripted path
81
+ gets it back as `?enquiry=<code>` on the page it came from.
82
+
83
+ A honeypot, always: a labelled input that is off-screen and out of the
84
+ accessibility tree — `aria-hidden`, `tabindex="-1"`, not `display: none`, which
85
+ some bots detect. A filled honeypot is answered with the same success a human
86
+ gets. Telling a bot it was caught only teaches it to try again differently.
87
+
88
+ ## Autofill repaints the fields
89
+
90
+ Chrome and Safari draw a filled field with their own background and their own
91
+ text colour. On a white form nobody notices; on a dark or tinted one the form
92
+ turns white and yellow the moment the browser helps, and it happens on the one
93
+ visit that matters — the one where the visitor's details are already saved.
94
+
95
+ There is no property that switches it off. `background` is ignored, `color` is
96
+ ignored; the background has to be painted over with a large inset shadow and the
97
+ text set through `-webkit-text-fill-color`. The base rule ships in
98
+ `src/styles/global.css` and is keyed to `--paper-deep` and `--ink`. A variant
99
+ that gives its inputs some other surface overrides those two properties rather
100
+ than deleting the rule.
101
+
102
+ Check it the way it breaks: fill the form once, submit, reload, then let the
103
+ browser autofill it. Do not check it by looking at an empty form.
104
+
105
+ ## Before the stage ends
106
+
107
+ Submit the form on the deployed site and watch the message arrive. Not the local
108
+ build, not a `curl`. The full path — browser, function, secret, bot, chat —
109
+ tested once, by a person.
package/docs/pages.md ADDED
@@ -0,0 +1,193 @@
1
+ # A second page
2
+
3
+ A landing page is one page on purpose, and most sites should stay that way: one
4
+ address, one message, one action, and everything a visitor needs before they
5
+ decide. Add a page when there is something a visitor genuinely goes looking
6
+ for — prices, a service they searched for by name, a place with an address — and
7
+ not to have somewhere to put the rest.
8
+
9
+ **Which pages a site has is this repository's to say.** There is no "add a page"
10
+ in the admin, and there should not be: a page is a route somebody wrote, and a
11
+ page invented in the admin would be an address that renders nothing. The one
12
+ exception is a collection's entries — [collections.md](collections.md).
13
+
14
+ ---
15
+
16
+ ## What a page costs
17
+
18
+ Five things, and every one of them is small.
19
+
20
+ **A route** — `src/pages/prices.astro`. Designed like the home page: it is a
21
+ page of this site, not a subpage of a template.
22
+
23
+ **Its blocks in the catalogue.** `src/content/blocks.ts` describes every block
24
+ on every page of the site; a new page usually brings one or two new types with
25
+ it. Reusing a type across pages is fine and common — a `questions` block belongs
26
+ on more than one page.
27
+
28
+ **A content document** — `jtk/content/prices.json`, committed with the
29
+ fields in it and a `path` of `/prices`. Empty strings are fine and are what the
30
+ scaffold ships for the home page; what is not fine is the file not existing,
31
+ because the build reads it and a missing one is a build error rather than an
32
+ empty page.
33
+
34
+ **A line in `PAGES`** — `src/data/site.ts`. It feeds the sitemap and `llms.txt`,
35
+ and it is written by hand so that adding a page and forgetting to list it is a
36
+ visible omission in one file rather than a silent one across the build.
37
+
38
+ **A way to get there.** A page nothing links to is a page nobody reads. That is
39
+ design, and it is the part worth thinking about longest.
40
+
41
+ ## Reading its content
42
+
43
+ The home page has `src/copy/<locale>.ts`, which is that page's own vocabulary —
44
+ `HOME.hero.title`, shaped by hand to the blocks it happens to have. Every other
45
+ page uses the generic reader instead, so that reshaping the home page's copy
46
+ cannot break how the rest of the site is read:
47
+
48
+ ```astro
49
+ ---
50
+ import Layout from '../layouts/Layout.astro';
51
+ import { readPage } from '../lib/page';
52
+
53
+ const prices = readPage('/prices');
54
+ const { at, of } = prices.block('rates');
55
+ ---
56
+
57
+ <Layout title={prices.seo.title} description={prices.seo.description}>
58
+ <main id="content">
59
+ <h1 data-jtk-path={prices.pathAt(at, 'title')}>{prices.str(of, 'title')}</h1>
60
+ <p data-jtk-path={prices.pathAt(at, 'lead')}>{prices.str(of, 'lead')}</p>
61
+ </main>
62
+ </Layout>
63
+ ```
64
+
65
+ `readPage` gives the same set the copy module does: `PAGE` (every block in
66
+ order), `block(type)`, `str`, `rows`, `picture`, `gallery`, and `pathAt`. It
67
+ finds the document by the `path` written inside it, not by the file's name,
68
+ because that is what publish writes and what the admin reads back.
69
+
70
+ **Every field rendered carries its `data-jtk-path`.** The annotation lint fails
71
+ the build without one, per page — and it is per page for a reason worth knowing:
72
+ two pages both rendering `blocks[0].title` are two different fields of two
73
+ different documents, and the check knows which is which.
74
+
75
+ ## What the admin does with it
76
+
77
+ Nothing, until the branch is imported. Import walks `jtk/content/`, creates
78
+ a page for every document it does not already have, and the page appears in the
79
+ site's list with its own drafts, versions and search fields. Publishing writes
80
+ every page back out again.
81
+
82
+ A page in the admin that the branch no longer has is **reported and never
83
+ deleted** — the branch may simply be an older ref, and a page may hold a draft
84
+ somebody is in the middle of.
85
+
86
+ ## Text that is on every page
87
+
88
+ A footer note, the line under the wordmark, the words over the form. One
89
+ sentence, on all of them.
90
+
91
+ **It does not go in a page's document.** Declared in each one, a three-page site
92
+ has three copies, and they diverge the first time somebody edits one: the owner
93
+ changes the footer on the prices page, looks at the home page, and reports that
94
+ their change disappeared. It did not — it was saved, on one page in three.
95
+
96
+ It goes in `jtk/shared.json`, once:
97
+
98
+ ```json
99
+ {
100
+ "schema_version": 1,
101
+ "blocks": [
102
+ { "_key": "chrome-1", "type": "chrome", "v": 1, "note": "Bookings go through the form." }
103
+ ]
104
+ }
105
+ ```
106
+
107
+ Its blocks are ordinary types from `blocks.ts` — nothing new to declare. What
108
+ makes them shared is where they live.
109
+
110
+ Read it with `readShared()`, and **annotate it on every page that renders it**:
111
+
112
+ ```astro
113
+ ---
114
+ import { readShared } from '../lib/page';
115
+ const chrome = readShared();
116
+ const { at, of } = chrome.block('chrome');
117
+ ---
118
+ <p data-jtk-path={chrome.pathAt(at, 'note')}>{chrome.str(of, 'note')}</p>
119
+ ```
120
+
121
+ `pathAt` writes `shared:blocks[0].note` — the prefix says which document the
122
+ field is in. Unlike a page's own field, **the same annotation on five pages is
123
+ correct**, and it is what makes the sentence editable wherever the owner happens
124
+ to be looking. The admin resolves it against the site's shared document rather
125
+ than the page in front of it, and tells them the line is on every page before
126
+ they change it.
127
+
128
+ A shared field is checked from the other side too: `jtk catalogue` refuses one
129
+ that no page renders — a control that edits nothing — and refuses a `shared:`
130
+ annotation for a field the document does not have.
131
+
132
+ It has the same three stages as everything else: a draft while it is being
133
+ typed, a saved version, and what the world sees. It is stored as a page that no
134
+ route renders, which is why none of that had to be built twice — the platform's
135
+ wiki/28 has the argument.
136
+
137
+ ## Every address inside the site goes under the base
138
+
139
+ A site is served two ways and the difference is a path. On its own host it is at
140
+ the root, so `/preise/` and `/favicon.png` mean what they say. **In the studio's
141
+ preview the same build is served under `https://preview…/p/<slug>/`**, where the
142
+ root is not the site: every one of those addresses leaves it.
143
+
144
+ What the client is then shown is a page with no stylesheet, no photographs and
145
+ navigation that 404s. It reads as a broken build rather than as a wrong prefix,
146
+ which is why it has cost three separate afternoons — the last of them on a site
147
+ built with this kit.
148
+
149
+ Two halves, and both are needed:
150
+
151
+ ```js
152
+ // astro.config.mjs — the path this build is served under, taken from the site
153
+ // URL rather than configured beside it. They are one decision.
154
+ const SITE = env.SITE_URL ?? 'http://localhost:4321/';
155
+ const BASE = new URL(SITE).pathname;
156
+
157
+ export default defineConfig({ site: SITE, base: BASE, /* … */ });
158
+ ```
159
+
160
+ `base` fixes what Astro emits — the bundled CSS, the optimised images, the
161
+ routes it generates. It cannot fix a string somebody typed. So the other half:
162
+
163
+ ```astro
164
+ ---
165
+ import { under } from '../lib/under';
166
+ ---
167
+ <a href={under('/preise/')}>Preise</a>
168
+ <link rel="icon" href={under('/favicon.png')} />
169
+ ```
170
+
171
+ That includes addresses that arrive as **data** — a nav list of
172
+ `{ label, href }` is rendered `href={under(item.href)}`, and a `current` compared
173
+ against `Astro.url.pathname` has to have the base taken off it first. A build for
174
+ a real host has a base of `/`, where `under()` changes nothing; that is the point
175
+ of writing every address one way.
176
+
177
+ `/api/…` is the exception: a form posts to the host it is served from, which is
178
+ the real site and is not part of the build.
179
+
180
+ `jtk catalogue` fails a repository that gets either half wrong, and proves the
181
+ second by building the site again under a path and holding every address it
182
+ emitted to it — so this is a rule that is checked rather than one to remember.
183
+
184
+ ## What a page is not
185
+
186
+ **Not a place for a link the owner edits.** A cross-page link's address is the
187
+ repository's: it points at a route somebody wrote, and an owner who could retype
188
+ it could break it with no way to know. Declare `cta_href` with
189
+ `client_editable: false`, or leave it out of the catalogue entirely.
190
+
191
+ **Not a blog post.** A page is a file. Anything the owner will keep adding to
192
+ over time — posts, works, prices that come and go — is a collection, and it
193
+ works differently: see [collections.md](collections.md).
package/docs/photos.md ADDED
@@ -0,0 +1,314 @@
1
+ # Photos
2
+
3
+ A small-business site is mostly photographs, and they arrive late, wrong-sized
4
+ and one at a time. The pipeline is built so that a missing photo is never a
5
+ build failure and swapping one never requires touching a page.
6
+
7
+ Template: `src/lib/photos.ts` and
8
+ `src/components/Shot.astro`.
9
+
10
+ ## Slots, not paths
11
+
12
+ ```
13
+ src/assets/<slot>.<ext> → src/lib/photos.ts (import.meta.glob, eager) → <Shot name="hero" />
14
+ ```
15
+
16
+ The file name is the slot. Any of `jpg jpeg png webp avif`, case-insensitive;
17
+ first match wins, so `hero.jpg` and `hero.webp` cannot fight over one slot.
18
+ Replacing a photo is dropping a file with the same name and rebuilding — no code
19
+ change, no page edit.
20
+
21
+ **A missing file must never break the build.** `photo()` returns `undefined` and
22
+ `<Shot>` renders a labelled placeholder that keeps the frame's aspect ratio, so
23
+ the layout survives and the gap is visible and named. This is what lets stage 1
24
+ proceed while the client is still looking for their photos, and it is the single
25
+ most useful thing in this file.
26
+
27
+ Do not move photos into `public/`. They need `astro:assets`, which is what
28
+ produces the webp variants, the `srcset` and the intrinsic dimensions.
29
+
30
+ ## Every photograph is either a field or the design
31
+
32
+ There is no third kind, and the difference is not how a picture is stored — it
33
+ is what the owner can do about it.
34
+
35
+ | | |
36
+ | --- | --- |
37
+ | **a field** | declared in `src/content/blocks.ts`, annotated on the page, tappable in the admin |
38
+ | **the design** | in a component, and only a commit changes it |
39
+
40
+ **A photograph that is on the page on purpose gets a field.** The portrait, the
41
+ room, the work in progress, the three frames of one piece: those are the
42
+ business, and a business whose owner cannot change their own portrait is one who
43
+ sends us a message and waits for a deploy. What stays out is a picture that is
44
+ structure rather than subject — a texture, a rule, a shape behind a heading.
45
+
46
+ Declaring one costs two lines and changes nothing about the page:
47
+
48
+ ```ts
49
+ // src/content/blocks.ts
50
+ { key: 'portrait', label: 'Світлина майстра', kind: 'media', client_editable: true },
51
+ { key: 'portrait_alt', label: 'Опис світлини', kind: 'text', max: 140,
52
+ client_editable: true, seo_sensitive: true, no_tap_target: true },
53
+ ```
54
+
55
+ ```astro
56
+ const { at, of } = block('artist');
57
+ const portrait = picture(of, at, 'portrait');
58
+ <Shot name={portrait.name} alt={portrait.alt} path={portrait.path} ratio="4 / 5" />
59
+ ```
60
+
61
+ The value in the content document is **the slot the repository already ships** —
62
+ `artist-at-work` — so the page looks exactly as it did, and the owner replaces
63
+ it when they want to. `<Shot>` resolves a slot and an uploaded key through the
64
+ same door, so what lands afterwards falls into the same frame.
65
+
66
+ `picture()` returns the slot, its words and its `data-jtk-path` together, and the
67
+ path is the half that cannot be skipped: without it the photograph is on the
68
+ page and nothing happens when the owner taps it. `annotation-lint` fails the
69
+ build for exactly that, and `<Shot path={...}>` puts it on the `<img>` — never
70
+ on the figure around it, because the admin replaces a picture by setting `src`
71
+ and an element that is not an image gets its *text* replaced instead.
72
+
73
+ A gallery is the other shape of the same rule — one field holding many, each
74
+ item with its own alt. See [gallery.md](gallery.md).
75
+
76
+ ## Alt text is registered, not improvised
77
+
78
+ (For a picture that is a field, the alt text is a sibling `<key>_alt` field and
79
+ the content document is what the page reads. The registry below still describes
80
+ the files, and is what a picture rendered straight from `src/assets` uses.)
81
+
82
+ Every slot is registered in `photos.ts` with alt text in the site's language and
83
+ a category. A photo with no alt is announced by its file name. Alt text is
84
+ written from what is actually in the frame; it does not repeat the caption and
85
+ it does not sell.
86
+
87
+ Two things not to assert: who a person is, unless the client confirmed it, and
88
+ what a photo proves. Do not pair two photographs of different subjects and label
89
+ them "before" and "after" — that is a claim about a result, and it needs to be
90
+ one client, one sequence, dated by them.
91
+
92
+ ### A picture that says nothing
93
+
94
+ `<Shot decorative>` renders `alt=""`, and that is not a missing alt — it is the
95
+ correct marking for a picture the page has already said in words. A screen
96
+ reader skips it, which is the point.
97
+
98
+ The case that makes it necessary is the `pile` hero: four photographs are one
99
+ thing to look at and four alt texts read out in a row, which is worse than one.
100
+ So the frame that carries the meaning keeps its alt and the rest are marked. It
101
+ is a composition decision, so it is per use rather than per file — the same
102
+ photograph is described in the section about it and decorative in the pile.
103
+
104
+ `fl-check` tells the two apart by reading the attribute rather than the
105
+ property: absent is the fault that gets announced as a file name, empty is a
106
+ decision. It still fails a page where *every* image is decorative, which is the
107
+ attribute being used to pass the check rather than to describe anything.
108
+
109
+ ## Scale
110
+
111
+ `src/assets/` is web scale: long side ≤ 1600px. Astro emits the source file
112
+ alongside the generated variants, so a 26MB camera JPEG ships 26MB whether or
113
+ not a browser ever asks for it — and it will be the reason mobile Lighthouse is
114
+ at 71.
115
+
116
+ Camera-scale originals live in `_photos-original/`, which nothing imports and
117
+ which stays out of the repository. Hand them to the client separately if
118
+ they want them.
119
+
120
+ ```bash
121
+ # ffmpeg is already a requirement and behaves the same everywhere:
122
+ for f in _photos-original/*.jpg; do
123
+ ffmpeg -loglevel error -y -i "$f" -vf "scale='min(1600,iw)':-2" "src/assets/$(basename "$f")"
124
+ done
125
+
126
+ sips -Z 1600 _photos-original/*.jpg --out src/assets/ # macOS shortcut
127
+ ```
128
+
129
+ ## Measure the photograph before you frame it
130
+
131
+ **A photograph is never stretched.** Not by a pixel. Distortion is the one image
132
+ fault a client always notices and never has words for — "it looks weird, but I
133
+ don't know why" — and it happens the same way every time: an `<img>` is given
134
+ both a width and a height by a layout that wanted a shape, and nothing tells it
135
+ to crop instead of squeeze. `<Shot>` sets `object-fit` in every branch so that
136
+ cannot happen; a variant that writes its own `<img>` has taken the rule off.
137
+
138
+ The other half of the rule is that the frame is *chosen*, not assumed. Ask the
139
+ picture what it is first:
140
+
141
+ ```astro
142
+ const owner = shape('owner'); // { width, height, ratio, orientation }
143
+ <Shot name="owner" ratio={owner?.orientation === 'portrait' ? '4 / 5' : '3 / 2'} />
144
+ ```
145
+
146
+ A 4:5 phone portrait dropped into a 16:9 band keeps 45% of itself, and the half
147
+ it loses is the half with the person in it. The build says so — `<Shot>` prints
148
+ the number whenever a `cover` crop would throw away more than 40% of a picture —
149
+ but it prints it after the decision, and `shape()` is how the decision is made
150
+ correctly in the first place.
151
+
152
+ ## `<Shot>`
153
+
154
+ Props: `name`, optional `alt` override, optional `ratio`, `fit`, `focus`,
155
+ `width`, `eager`, `sizes`, `caption`.
156
+
157
+ - **Omit `ratio` where the photograph's own proportions matter.** Client photos
158
+ are phone snapshots of small things shot in every orientation; a square crop
159
+ cuts the subject out of half of them. Use a fixed ratio only for fixed-size
160
+ previews, where a ragged bottom edge reads as a bug.
161
+ - **`focus` decides what a crop keeps.** The default centres, which is wrong for
162
+ nearly every photograph of a person: a face sits in the top third and a wide
163
+ frame crops it off at the chin. `focus="50% 25%"` is the usual fix, and it is
164
+ worth looking at rather than assuming — the subject is not always where the
165
+ camera put it.
166
+ - **`fit="contain"` where nothing may be cropped** — a logo, a document, a
167
+ photograph of something long. The plate shows around it, which is the honest
168
+ trade and not a bug.
169
+ - **`eager` on the first contentful image only.** Everything else lazy. One
170
+ `fetchpriority="high"` per page; two means neither is prioritised.
171
+ - **`sizes` describes the layout, not the file.** A wrong `sizes` is why a
172
+ correctly generated `srcset` still downloads the 2400px variant on a phone.
173
+
174
+ ## Grouped galleries
175
+
176
+ A gallery of client photos wants a column layout that keeps every photo whole
177
+ and ends ragged, not a grid that crops to squares and ends flush. The ragged
178
+ edge is worth the honesty: cropping is how a close-up of one fingertip loses the
179
+ fingertip.
180
+
181
+ ## Whose face is in the hero
182
+
183
+ For a business run by one person, **the hero photograph is that person**, and
184
+ finding one is worth real effort. It is their business, they are what a customer
185
+ is actually choosing, and a stranger's decision to book is made about a face
186
+ long before it is made about a service list. A hero showing hands, a room or a
187
+ product is the fallback, not the target.
188
+
189
+ Two things this is not. It is not permission to use a *customer's* face — that
190
+ belongs to somebody who never agreed to appear in anybody's sales material. And
191
+ on the outbound track it is not a claim about who the person is: a portrait
192
+ lifted from their own feed goes in the hero because it is theirs, with alt text
193
+ that describes the frame rather than naming them.
194
+
195
+ Where the photograph is a person, the frame is chosen with more care than
196
+ anywhere else on the page: a portrait keeps portrait proportions, `focus` keeps
197
+ the face out of the crop, and the picture is looked at at phone width, where the
198
+ hero is tallest and narrowest.
199
+
200
+ ## Sensitive photographs
201
+
202
+ Some trades document damage — medical, cosmetic, repair. Those pictures are
203
+ often the strongest evidence a business has, and they belong on the page that
204
+ describes that treatment, where the visitor chose to look. Not in a hero, not on
205
+ a card, not in a preview thumbnail. Ask before publishing anyone recognisable.
206
+
207
+ ## Treatment
208
+
209
+ Cropping them well leaves them still disagreeing.
210
+
211
+ A client's photographs arrive as a feed: twenty pictures in twenty lights, shot
212
+ on three phones over two years, with white balance disagreeing between every
213
+ pair. That disagreement is what makes a page read as *not art-directed* — which
214
+ is exactly what it is. Framing does not fix it, because it is not a framing
215
+ fault.
216
+
217
+ A treatment is what makes them one set. `<Shot treatment="…">`, chosen once for
218
+ the site in `jtk/design.json`:
219
+
220
+ ```jsonc
221
+ "photos": { "treatment": "grade", "amount": 0.8 }
222
+ ```
223
+
224
+ It is two blended layers over the picture and a filter on it — no build step, no
225
+ second copy of any file, nothing to re-run when a photo is replaced, and where
226
+ the blend modes do not land what is left is the photograph.
227
+
228
+ | `treatment` | What it is | Wants |
229
+ | --- | --- | --- |
230
+ | `none` | The photograph. | Photographs that already agree — one shoot, one light. Rare, and worth checking rather than assuming. |
231
+ | `grade` | The page's own light: colour kept, shadows pulled toward the ground's hue, highlights toward the paper. | Nearly every site. It is the one that fixes the disagreement without announcing itself, and the only one that is safe on a face. |
232
+ | `duotone` | The colour is thrown away and the tonal range remapped onto two tokens. | A page whose photographs disagree badly — because their original colour is precisely what was disagreeing. Strongest, loudest, and forbidden on their face. |
233
+ | `film` | Lifted blacks, a milky highlight, grain. | Making a phone photograph look like it was taken on purpose. Pairs with the `grain` ground, which is the same noise tile. |
234
+ | `press` | One ink, hard contrast, a dot lattice. The photograph stops pretending to be a photograph. | A page whose ground is already `riso`, `hatch` or `press`-shaped. Forbidden on their face. |
235
+ | `recede` | The picture pushed back into the page under a veil of the ground. | A photograph that copy has to sit on. It is the band-sized answer to what `scrim` does for the hero's canvas. |
236
+
237
+ ### One treatment per site
238
+
239
+ Two looks on one page is the same disagreement arrived at from the other
240
+ direction, and it is the shape a page takes when each section was styled on the
241
+ day it was written rather than decided once. `fl-check` fails it.
242
+
243
+ `recede` does not count: it is a photograph doing a job, not a look, and it sits
244
+ alongside whichever one the page chose.
245
+
246
+ ### Never a strong one on their face
247
+
248
+ `duotone` and `press` replace skin colour. On a one-person business that face is
249
+ what the customer is choosing — turned two-tone it reads as stock art, which is
250
+ the opposite of everything the photograph is there to say.
251
+
252
+ So the slot is registered, and the component refuses:
253
+
254
+ ```ts
255
+ { name: 'owner', alt: '…', person: true }
256
+ ```
257
+
258
+ `<Shot>` fails the build rather than shipping it. The escape is explicit and per
259
+ photo — `treatment="grade"`, which is allowed on a face and does most of the
260
+ work anyway.
261
+
262
+ The check is one-way on purpose: it can stop a treatment reaching a face
263
+ somebody registered, and it cannot know about one nobody did. Registering
264
+ `person` is part of registering the photograph, like its alt text.
265
+
266
+ ### It is in their ink
267
+
268
+ Nothing in a treatment names a colour. The layers are `--photo-shadow`,
269
+ `--photo-light`, `--photo-ink` and `--photo-veil`, which are tokens — so a
270
+ treatment follows the variant's palette and the studio bar's live accent the
271
+ same way a ground does, and a photograph cannot end up graded into somebody
272
+ else's brand.
273
+
274
+ `--photo-shadow` carries the ground's hue on purpose: a neutral black in the
275
+ shadows of a photograph on a warm page turns them grey, which reads as dirty
276
+ rather than photographed. It is the same rule as the shadow tokens in
277
+ [css.md](css.md).
278
+
279
+ `--photo-amount` is the volume knob and multiplies every layer. At 0 the
280
+ photograph is untouched, which is what makes "a bit less" a number rather than
281
+ an argument.
282
+
283
+ **On a dark page the two ends swap over.** A photograph's shadows are dark on
284
+ any page, but `--ink` and `--paper` invert — so a treatment derived from them
285
+ comes out upside down, and `duotone` folds a photograph into a flat rectangle.
286
+ It looks like a broken blend mode and it is a palette read the wrong way up.
287
+ `Layout.astro` compares the luminance of `paper` and `ink` in the design
288
+ document and swaps the two tokens itself; a variant that sets its palette some
289
+ other way has to do the same.
290
+
291
+ ### What it costs, and what it does not
292
+
293
+ No extra bytes: the layers are two empty spans and the noise tile is the same
294
+ one the `grain` ground uses (`src/lib/noise.ts` — one copy, because two copies
295
+ of a texture is how one of them gets retuned and the other does not).
296
+
297
+ It is `mix-blend-mode` and `filter`, which are composited: cheap on a handful of
298
+ photographs, and worth a look on a gallery of twenty. Print drops all of it —
299
+ ink is expensive and a treatment is not the picture.
300
+
301
+ True film halation — the bright pass blurred back over the picture — is
302
+ deliberately absent. It costs a second copy of every image and a full-width blur
303
+ to composite, and on a page of eight photographs that is paid eight times for the
304
+ last tenth of the effect.
305
+
306
+ ## The social card
307
+
308
+ `public/og.png` (or `.jpg`), 1200×630, regenerated whenever the hero photograph
309
+ or the wordmark changes. A photograph belongs in a JPEG — the same card as a PNG
310
+ was 600KB, and every social crawler downloads it.
311
+
312
+ Generate it from the site's own assets with a script rather than by hand, so it
313
+ cannot fall out of step. Check it on the served site, because a broken
314
+ card is the first impression on every share.