@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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/bin/jtk.mjs +41 -0
- package/docs/booking.md +164 -0
- package/docs/catalogue.md +459 -0
- package/docs/collections.md +249 -0
- package/docs/css.md +86 -0
- package/docs/gallery.md +127 -0
- package/docs/hero-motion.md +189 -0
- package/docs/kit.md +454 -0
- package/docs/languages.md +182 -0
- package/docs/lead-form.md +109 -0
- package/docs/pages.md +193 -0
- package/docs/photos.md +314 -0
- package/docs/scaffold.md +75 -0
- package/docs/shapes.md +140 -0
- package/docs/surface.md +187 -0
- package/lib/catalogue.mjs +1678 -0
- package/lib/codes.mjs +171 -0
- package/lib/create.mjs +282 -0
- package/package.json +16 -0
- package/template/astro.config.mjs +84 -0
- package/template/figures.mjs +122 -0
- package/template/gitignore +16 -0
- package/template/jtakeit-meta.mjs +112 -0
- package/template/jtk/content/index.json +38 -0
- package/template/jtk/design.json +24 -0
- package/template/markdown.mjs +36 -0
- package/template/package-lock.json +5320 -0
- package/template/package.json +26 -0
- package/template/specimens.mjs +46 -0
- package/template/src/components/Blocks.astro +151 -0
- package/template/src/components/BookingForm.astro +506 -0
- package/template/src/components/Clip.astro +155 -0
- package/template/src/components/Hero.astro +66 -0
- package/template/src/components/LeadForm.astro +347 -0
- package/template/src/components/OpeningHours.astro +69 -0
- package/template/src/components/Pile.astro +185 -0
- package/template/src/components/Shot.astro +472 -0
- package/template/src/components/gallery/Gallery.astro +381 -0
- package/template/src/components/gallery/galleries.ts +139 -0
- package/template/src/components/motion/HeroField.astro +520 -0
- package/template/src/components/motion/fields.ts +430 -0
- package/template/src/components/surface/Pattern.astro +278 -0
- package/template/src/components/surface/patterns.ts +187 -0
- package/template/src/content/blocks.ts +758 -0
- package/template/src/content.config.ts +19 -0
- package/template/src/copy/LOCALE.ts +324 -0
- package/template/src/data/site.ts +137 -0
- package/template/src/layouts/Layout.astro +282 -0
- package/template/src/lib/alive.ts +49 -0
- package/template/src/lib/entries.ts +106 -0
- package/template/src/lib/entryLoader.ts +315 -0
- package/template/src/lib/noise.ts +26 -0
- package/template/src/lib/page.ts +287 -0
- package/template/src/lib/photos.ts +168 -0
- package/template/src/lib/under.ts +32 -0
- package/template/src/lib/uploads.ts +85 -0
- package/template/src/pages/[...entry].astro +207 -0
- package/template/src/pages/[...feed].xml.ts +64 -0
- package/template/src/pages/index.astro +90 -0
- package/template/src/pages/llms.txt.ts +50 -0
- package/template/src/pages/privacy.astro +59 -0
- package/template/src/pages/robots.txt.ts +21 -0
- package/template/src/pages/sitemap.xml.ts +50 -0
- package/template/src/styles/global.css +411 -0
- package/template/src/styles/surface.css +375 -0
- 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.
|