@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,249 @@
|
|
|
1
|
+
# Collections — a blog, a portfolio, a price list
|
|
2
|
+
|
|
3
|
+
## Only when it is asked for
|
|
4
|
+
|
|
5
|
+
**Never add a collection because a site could have one.** No brief that does not
|
|
6
|
+
name it gets a blog, and no site gets one "for later". The scaffold declares
|
|
7
|
+
none, and that is the correct state for almost every site this studio ships.
|
|
8
|
+
|
|
9
|
+
The reason is not tidiness. A collection is a standing obligation on the person
|
|
10
|
+
who owns the site: an empty blog on a live page says the business stopped
|
|
11
|
+
caring in March, and a blog with three posts from a year ago says it louder. A
|
|
12
|
+
landing page that never mentions one says nothing at all. Handing somebody a
|
|
13
|
+
weekly job they did not ask for is worse than handing them a smaller site.
|
|
14
|
+
|
|
15
|
+
So the bar is a sentence from the person commissioning the work — the studio,
|
|
16
|
+
the developer, the client — that names the thing: *«потрібен блог»*, *«хочу
|
|
17
|
+
розділ з роботами»*. Not "it would be nice", not inferred from a vertical, not
|
|
18
|
+
because the design has room. If the brief is silent, the answer is silence.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
A collection is a set of **entries the owner creates in the admin** and this
|
|
23
|
+
repository renders. It is the one place the admin makes something the
|
|
24
|
+
repository did not, and the line is drawn once:
|
|
25
|
+
|
|
26
|
+
> **This repository declares the shape. The admin creates the entries.**
|
|
27
|
+
|
|
28
|
+
Everywhere else, which pages a site has is yours: pages arrive by import, and
|
|
29
|
+
the admin has no "add a page" — a page invented there would have no route to
|
|
30
|
+
render it. A collection changes exactly one thing: you write the shape and the
|
|
31
|
+
two routes, and the owner writes the posts.
|
|
32
|
+
|
|
33
|
+
So there is no "blog feature" in the platform. A blog is one collection whose
|
|
34
|
+
prefix is `/blog`, ordered by date. A portfolio, a menu, a vacancy board and a
|
|
35
|
+
price list are the same mechanism with different words.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## What you write
|
|
40
|
+
|
|
41
|
+
**One entry in `src/content/blocks.ts`**, beside the blocks:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
export const COLLECTIONS: Collection[] = [
|
|
45
|
+
{ name: 'blog', label: 'Блог', prefix: '/blog', type: 'post',
|
|
46
|
+
order: { by: 'date', desc: true }, per_page: 10 },
|
|
47
|
+
];
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**And the block type its entries are made of**, in `BLOCKS` like any other:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
{
|
|
54
|
+
type: 'post',
|
|
55
|
+
v: 1,
|
|
56
|
+
label: 'Допис',
|
|
57
|
+
fields: [
|
|
58
|
+
{ key: 'title', label: 'Заголовок', kind: 'text', required: true, max: 90, client_editable: true, seo_sensitive: true },
|
|
59
|
+
{ key: 'date', label: 'Дата', kind: 'date', required: true, client_editable: true, no_tap_target: true },
|
|
60
|
+
{ key: 'excerpt', label: 'Анонс', kind: 'textarea', max: 200, client_editable: true, no_tap_target: true },
|
|
61
|
+
{ key: 'cover', label: 'Обкладинка', kind: 'media', client_editable: true },
|
|
62
|
+
{ key: 'body', label: 'Текст допису', kind: 'markdown', max: 40000, client_editable: true },
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**And the listing page** — `src/pages/blog/index.astro` — which is an ordinary
|
|
68
|
+
page of yours, designed like every other:
|
|
69
|
+
|
|
70
|
+
```astro
|
|
71
|
+
---
|
|
72
|
+
import { listed, href, collectionNamed } from '../../lib/entries';
|
|
73
|
+
const blog = collectionNamed('blog');
|
|
74
|
+
const posts = await listed('blog');
|
|
75
|
+
---
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`listed()` is the only thing that enumerates entries, and it does two things you
|
|
79
|
+
must not do by hand: it drops the ones nobody has finished, and it sorts by the
|
|
80
|
+
collection's own order.
|
|
81
|
+
|
|
82
|
+
## What you get without writing it
|
|
83
|
+
|
|
84
|
+
| | |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `src/pages/[...entry].astro` | one entry of **any** collection. Ships with the scaffold; a new collection needs no new route |
|
|
87
|
+
| `/blog/rss.xml` | one feed per collection |
|
|
88
|
+
| `sitemap.xml`, `llms.txt` | the entries are in both, the unfinished ones in neither |
|
|
89
|
+
| `dist/_meta.json` | the list of unfinished entries, which is what lets the studio's edge keep them private |
|
|
90
|
+
|
|
91
|
+
The entry route is a **starting point, not a design** — exactly like
|
|
92
|
+
`Blocks.astro`. Replace what is inside `<article>`. What must survive is the
|
|
93
|
+
annotation.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## The five reserved keys
|
|
98
|
+
|
|
99
|
+
An entry's block type may declare whatever this business needs, and five names
|
|
100
|
+
are spoken for, because the listing, the feed, the sitemap, the card a messenger
|
|
101
|
+
draws and the admin's own list of entries all read them by name:
|
|
102
|
+
|
|
103
|
+
| | |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `title` | text, **required**. Everything above needs one. |
|
|
106
|
+
| `date` | date. Required if the collection is ordered by it. |
|
|
107
|
+
| `excerpt` | textarea. The sentence under the title in a listing. |
|
|
108
|
+
| `cover` | media, one picture — never `multiple`. The card, the listing, `og:image`. |
|
|
109
|
+
| `body` | markdown. The entry itself. |
|
|
110
|
+
|
|
111
|
+
Leave out any that make no sense — a set of works has no excerpt. What you may
|
|
112
|
+
not do is give one of those names to something else; `jtk catalogue` refuses it.
|
|
113
|
+
|
|
114
|
+
Four more names belong to the entry's *file* rather than to a field of it, and
|
|
115
|
+
are refused as field keys for the same reason: `collection`, `visible`, `seo`,
|
|
116
|
+
`path`.
|
|
117
|
+
|
|
118
|
+
## Annotation: two rules
|
|
119
|
+
|
|
120
|
+
**An entry's document is one block**, so its paths are always
|
|
121
|
+
`blocks[0].<field>` — `blocks[0].title`, `blocks[0].body`.
|
|
122
|
+
|
|
123
|
+
**A listing page carries no annotations at all.** The titles on it are other
|
|
124
|
+
pages' words; a tap there would send a path the admin resolves against the
|
|
125
|
+
listing's own document, which is a different page. `jtk catalogue` refuses a
|
|
126
|
+
built page that has annotations and no content document of its own. The owner
|
|
127
|
+
edits an entry by opening it.
|
|
128
|
+
|
|
129
|
+
A field the entry page does not render is `no_tap_target: true` — that is
|
|
130
|
+
usually `date` and `excerpt`, which live on the listing rather than on the post.
|
|
131
|
+
|
|
132
|
+
## Pictures in a body
|
|
133
|
+
|
|
134
|
+
Ordinary markdown, and the address is a media key exactly as everywhere else:
|
|
135
|
+
|
|
136
|
+
```markdown
|
|
137
|
+

|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The alt text is heard by whoever cannot see it; **the title slot is the
|
|
141
|
+
caption**, printed under the picture. Two sentences, two jobs, two slots that
|
|
142
|
+
CommonMark has had all along.
|
|
143
|
+
|
|
144
|
+
### A figure, and a row
|
|
145
|
+
|
|
146
|
+
A paragraph holding nothing but pictures becomes a figure, or a row of them:
|
|
147
|
+
|
|
148
|
+
```markdown
|
|
149
|
+
 one paragraph, one picture → a figure
|
|
150
|
+
|
|
151
|
+
 ONE paragraph, three → a row
|
|
152
|
+

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