@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
package/docs/kit.md
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# The kit
|
|
2
|
+
|
|
3
|
+
The stack, and the conventions that came out of building a dozen of these. Every
|
|
4
|
+
rule here exists because something broke without it.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
Astro 7, static output no adapter, no SSR, no server to keep alive
|
|
8
|
+
jtakeit builds and serves it and answers /api/lead and the diary's endpoints beside the site
|
|
9
|
+
TypeScript, astro check at zero errors, warnings and hints
|
|
10
|
+
No UI framework by default React only for an island that earns it
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Add a framework when a component genuinely needs state across interactions, not
|
|
14
|
+
before.
|
|
15
|
+
|
|
16
|
+
> Sentences below that name `fl-check` refer to Fastlane Studio's own composition
|
|
17
|
+
> check, which this package does not ship. Read each one as a rule to hold the
|
|
18
|
+
> page to — by measuring the built page yourself.
|
|
19
|
+
|
|
20
|
+
## The rules that are not negotiable
|
|
21
|
+
|
|
22
|
+
**Comments and identifiers are English. User-facing copy is not.** Every
|
|
23
|
+
sentence a visitor reads lives in one copy module — `src/copy/<locale>.ts` or
|
|
24
|
+
`src/data/content.ts` — and never inside a component. A string in a client
|
|
25
|
+
script is passed through a `data-*` attribute, never hardcoded. This is what
|
|
26
|
+
makes a round of revisions a series of one-line edits instead of a grep.
|
|
27
|
+
|
|
28
|
+
**Facts live in `src/data/site.ts`.** Name, address in parts, both forms of the
|
|
29
|
+
phone number, email, socials, price range, map link. Rendered from there
|
|
30
|
+
everywhere, so the number a visitor reads and the number a phone dials cannot
|
|
31
|
+
drift apart, and structured data cannot disagree with the footer.
|
|
32
|
+
|
|
33
|
+
**The page works with JavaScript off.** Text, photos, layout and navigation are
|
|
34
|
+
static HTML. The form posts to its endpoint and gets a plain HTML answer back.
|
|
35
|
+
Script adds reveals, in-place submission and niceties — it is never the reason
|
|
36
|
+
something is visible. Mark the document scripted before first paint with a
|
|
37
|
+
blocking inline `document.documentElement.classList.add('js')`, and hang every
|
|
38
|
+
hidden start state on `html.js`, or a no-JS visitor stares at `opacity: 0`
|
|
39
|
+
forever.
|
|
40
|
+
|
|
41
|
+
**One flag controls indexability.** `INDEXABLE` in `src/data/site.ts` feeds the
|
|
42
|
+
robots meta tag, `robots.txt` and the sitemap. Three hand-maintained places is
|
|
43
|
+
how a site ships still telling Google to go away.
|
|
44
|
+
|
|
45
|
+
**The host is a build input.** `astro.config.mjs` reads `SITE_URL`;
|
|
46
|
+
`src/data/site.ts` reads it back out of `import.meta.env.SITE`. Canonicals, OG
|
|
47
|
+
tags and the sitemap then follow whatever host the build was made for, and
|
|
48
|
+
there is no second place to forget.
|
|
49
|
+
|
|
50
|
+
**A collection is added only when it was asked for.** A blog, a portfolio, a
|
|
51
|
+
price list — a set of entries the owner keeps adding to — is a standing weekly
|
|
52
|
+
obligation on somebody who did not ask for one, and an empty one on a live page
|
|
53
|
+
says the business stopped caring. The scaffold declares none, and that is the
|
|
54
|
+
right state for almost every site. The bar is a sentence in the brief that names
|
|
55
|
+
it; not a vertical it would suit, not room in the design, not "for later". See
|
|
56
|
+
[collections.md](collections.md).
|
|
57
|
+
|
|
58
|
+
**`npm run check` stays at 0 / 0 / 0.** Not "only warnings". The first
|
|
59
|
+
tolerated warning is the last useful run of that command.
|
|
60
|
+
|
|
61
|
+
## Layout
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
src/
|
|
65
|
+
assets/ photos, web scale (long side ≤ 1600px)
|
|
66
|
+
components/ one directory per variant + shared/
|
|
67
|
+
motion/ the hero fields — one canvas, no dependencies
|
|
68
|
+
copy/<locale>.ts every user-facing sentence
|
|
69
|
+
data/site.ts the business's facts + INDEXABLE
|
|
70
|
+
layouts/ <head>, SEO, the inline pre-paint script
|
|
71
|
+
lib/photos.ts slot → file, via import.meta.glob
|
|
72
|
+
pages/ routes; robots.txt.ts and sitemap.xml.ts are routes too
|
|
73
|
+
styles/ tokens and globals
|
|
74
|
+
jtk/ the catalogue, the content and the design document — the owner's half
|
|
75
|
+
public/ favicons, the og image, clips
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## The pieces
|
|
79
|
+
|
|
80
|
+
- **The lead form and its endpoint** — [lead-form.md](lead-form.md)
|
|
81
|
+
- **Online booking** — [booking.md](booking.md): the
|
|
82
|
+
platform's diary drawn by the page — six addresses, a `BookingForm.astro`
|
|
83
|
+
to copy, and why no free time is ever computed in the repository
|
|
84
|
+
- **Photos, and how they are printed** — [photos.md](photos.md)
|
|
85
|
+
- **A site in more than one language** — [languages.md](languages.md):
|
|
86
|
+
the address that pairs translations, and the `hreflang` that stops them competing
|
|
87
|
+
- **A second page** — [pages.md](pages.md): what one costs,
|
|
88
|
+
why the admin cannot make one, and **why every internal address goes through
|
|
89
|
+
`under()`** — the studio's preview serves this build under `/p/<slug>/`, where
|
|
90
|
+
a link written from the root leaves the site
|
|
91
|
+
- **A blog, a portfolio, a price list** — [collections.md](collections.md):
|
|
92
|
+
sets of entries the owner creates and this repository renders
|
|
93
|
+
- **The work, shown** — [gallery.md](gallery.md): six
|
|
94
|
+
arrangements for a body of work, the one of them that is allowed to move, and
|
|
95
|
+
how a gallery is fed from content so the owner keeps it rather than us.
|
|
96
|
+
- **Their own video, cut into loops** — `<Clip>`, which carries every rule a
|
|
97
|
+
silent autoplaying loop has to meet; the cutting is yours.
|
|
98
|
+
- [catalogue.md](catalogue.md) — `jtk/catalogue.json`: what the admin
|
|
99
|
+
may edit, declared in `src/content/blocks.ts` and emitted by `jtk catalogue`.
|
|
100
|
+
Without it a site cannot be attached to the admin at all.
|
|
101
|
+
- **The hero's moving ground** — [hero-motion.md](hero-motion.md)
|
|
102
|
+
- **Grounds, edges and entrances** — [surface.md](surface.md)
|
|
103
|
+
- **The shapes a page can take** — [shapes.md](shapes.md)
|
|
104
|
+
- **Design tokens and CSS discipline** — [css.md](css.md)
|
|
105
|
+
- **The scaffold, and what the platform holds it to** — [scaffold.md](scaffold.md)
|
|
106
|
+
|
|
107
|
+
## Motion
|
|
108
|
+
|
|
109
|
+
Animation is welcome and it is cheap to overdo. Three rules keep it on the right
|
|
110
|
+
side, and one component is allowed to be louder than all of them:
|
|
111
|
+
|
|
112
|
+
- **`prefers-reduced-motion: reduce` is honoured everywhere**, including
|
|
113
|
+
scroll-driven and sprite animations. Reduced motion means the finished state,
|
|
114
|
+
not a broken one.
|
|
115
|
+
- **Motion comes from tokens** — `--dur`, `--ease`. A bare `150ms ease` in a new
|
|
116
|
+
component is a drift, not a choice.
|
|
117
|
+
- **Nothing moves while it is being *read*.** No carousels that advance past a
|
|
118
|
+
sentence somebody is halfway through. A row that does not fit reflows:
|
|
119
|
+
4 → 2 → 1.
|
|
120
|
+
|
|
121
|
+
A wall of photographs is looked at rather than read, and that is the one
|
|
122
|
+
arguable exception — `<Gallery arrangement="wall">` takes it, on three
|
|
123
|
+
conditions written into the component: it stops on hover and on keyboard
|
|
124
|
+
focus, reduced motion gets the whole thing laid out still, and nothing
|
|
125
|
+
readable is inside it. A rail carrying words does not get the same exception,
|
|
126
|
+
because the first condition cannot save it.
|
|
127
|
+
|
|
128
|
+
- **Anything a script starts is started through `onAlive`** (`src/lib/alive.ts`).
|
|
129
|
+
Not a style rule — a working one. The admin shows the client this site while
|
|
130
|
+
they write, and when they put a gallery into a post it takes the arrangement
|
|
131
|
+
from a specimen page this build made and puts it on the page. Everything
|
|
132
|
+
declarative arrives right; anything a script was meant to do to it does not,
|
|
133
|
+
unless the script can be asked for one subtree. `onAlive` is that shape, and
|
|
134
|
+
it is also what survives a view transition. Where an arrangement genuinely
|
|
135
|
+
cannot be — a script that measures the whole article — say `needs_build: true`
|
|
136
|
+
on the view and the preview will not pretend.
|
|
137
|
+
|
|
138
|
+
- **Whatever moves forever declares itself.** A component that owns continuous
|
|
139
|
+
motion marks its root `data-motion="<name>"`; `fl-check` counts those regions
|
|
140
|
+
rather than counting animated elements, so a wall of three drifting columns is
|
|
141
|
+
one decision rather than three faults. Two regions is the ceiling — a hero that
|
|
142
|
+
moves and a wall that drifts — and anything moving forever that neither
|
|
143
|
+
declares itself nor can be pressed is the fault the check is for.
|
|
144
|
+
|
|
145
|
+
**The hero is the one place a page is allowed a "wow".** A landing gets about a
|
|
146
|
+
second to look built rather than assembled, and a field of colour that is
|
|
147
|
+
visibly alive buys that second where a still gradient does not.
|
|
148
|
+
`src/components/motion/HeroField.astro` is the set — ten fields, nine shaders
|
|
149
|
+
and a line field, no dependencies, with every failure path ending at the same
|
|
150
|
+
still CSS ground. It is the exception to "restrained", not a licence to animate
|
|
151
|
+
the rest.
|
|
152
|
+
|
|
153
|
+
**Its colours are a required argument, not a default.** The page does not
|
|
154
|
+
type-check until four colours and a ground have been chosen for this business,
|
|
155
|
+
off their own photographs — a field in a shader's gallery palette is the
|
|
156
|
+
loudest tell that a page was generated. Read
|
|
157
|
+
[hero-motion.md](hero-motion.md) before using it: the
|
|
158
|
+
parts that matter are the palette it is given and the ways it is allowed to
|
|
159
|
+
fail.
|
|
160
|
+
|
|
161
|
+
**Everything below the hero is the surface, and it has three catalogues of its
|
|
162
|
+
own** — the ground the page is printed on, the edge a block is drawn with, and
|
|
163
|
+
how a block arrives — and, in [photos.md](photos.md), how
|
|
164
|
+
its photographs are printed. Six grounds, nine edges, seven entrances, six
|
|
165
|
+
treatments, all chosen in `jtk/design.json` and all of which can be *none*:
|
|
166
|
+
[surface.md](surface.md). They cost no canvas and no
|
|
167
|
+
script, they are painted in the variant's own tokens, and they are the answer to
|
|
168
|
+
a page that reads as bare without turning the hero up.
|
|
169
|
+
|
|
170
|
+
Four of the rules there are measured rather than argued about, because each was
|
|
171
|
+
invisible in the window it was built in: a reveal that never fires is content
|
|
172
|
+
nobody can read, a reveal above the fold delays the largest paint, more than one
|
|
173
|
+
thing moving forever is a demo, and a ground turned up past 1.6:1 against its
|
|
174
|
+
paper competes with the text on it. `fl-check` fails all four.
|
|
175
|
+
|
|
176
|
+
Scroll-driven CSS animation is the good tool here, with two traps learned the
|
|
177
|
+
hard way. Time an element at the top of the page against `scroll(root block)`,
|
|
178
|
+
not `view()` — a view timeline counts an element already on screen as partly
|
|
179
|
+
spent. And measure vertical travel in `vh`, never `%`: the element scrolls away
|
|
180
|
+
while it animates, so what is seen is its travel minus the scroll distance, and
|
|
181
|
+
a percentage measures against the element's own box, which is a quarter the size
|
|
182
|
+
on a phone.
|
|
183
|
+
|
|
184
|
+
## Not the same page every time
|
|
185
|
+
|
|
186
|
+
Everything below this line is a floor: things that are wrong on any page,
|
|
187
|
+
measured rather than argued about. None of it decides what the page *is*.
|
|
188
|
+
|
|
189
|
+
That distinction matters more than it sounds, because the examples in a floor
|
|
190
|
+
are the thing that gets copied. Measured across three consecutive spec builds
|
|
191
|
+
for three unrelated businesses: the same hero markup class for class, the same
|
|
192
|
+
stack of full-bleed bands under it, the same serif-display-over-sans pairing.
|
|
193
|
+
What actually differed was the palette, the fonts and one bespoke component.
|
|
194
|
+
|
|
195
|
+
So before writing any of it, choose the shape from
|
|
196
|
+
[shapes.md](shapes.md) — one hero anatomy of seven, one page
|
|
197
|
+
form of six, one type pairing of six — and record the choice in the state file.
|
|
198
|
+
A shape chosen because it was the example printed in this document is how a
|
|
199
|
+
studio acquires a house style nobody designed.
|
|
200
|
+
|
|
201
|
+
## Composition floor
|
|
202
|
+
|
|
203
|
+
A page is not finished because it looks finished in the window it was built in.
|
|
204
|
+
The same faults turn up on every project, none of them visible without
|
|
205
|
+
measuring, and all of them cost minutes to check and an afternoon to discover
|
|
206
|
+
late.
|
|
207
|
+
|
|
208
|
+
**The first screen holds the whole hero, the one action included.** Eyebrow,
|
|
209
|
+
heading, lead and the button that does the thing all land inside the viewport on
|
|
210
|
+
a short laptop — 1280×800, which is most of them — as much as on a phone. A call
|
|
211
|
+
to action half a thumb below the fold is a call to action that does not exist,
|
|
212
|
+
and it is the commonest defect in a page that "looked fine on my monitor". The
|
|
213
|
+
photograph is the part that gives way: measure the frame against the viewport and
|
|
214
|
+
let the picture crop into it, rather than padding by feel and hoping it lands.
|
|
215
|
+
|
|
216
|
+
```css
|
|
217
|
+
/* One anatomy of six — the split hero, text beside media. It is written out
|
|
218
|
+
here because it is the one where the measuring is hardest to get right, NOT
|
|
219
|
+
because it is the one to build. Three consecutive spec builds shipped this
|
|
220
|
+
exact markup with different colours on it; the other five anatomies are in
|
|
221
|
+
references/shapes.md and one of them is probably the right answer. */
|
|
222
|
+
.hero {
|
|
223
|
+
min-block-size: calc(100svh - 4rem);
|
|
224
|
+
align-items: center;
|
|
225
|
+
}
|
|
226
|
+
/* The frame is measured; the picture crops into it and still fills its column. */
|
|
227
|
+
.hero__media :global(.shot) { block-size: 30svh; }
|
|
228
|
+
.hero__media :global(.shot img) { block-size: 100%; object-fit: cover; }
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Note what the second rule is for: give the frame an `aspect-ratio` instead and
|
|
232
|
+
capping its height shrinks its *width* too, so the photograph stops filling its
|
|
233
|
+
column and sits there looking accidentally small.
|
|
234
|
+
|
|
235
|
+
The same measuring, in an anatomy that is not the split — a full-bleed
|
|
236
|
+
photograph with the words on it:
|
|
237
|
+
|
|
238
|
+
```css
|
|
239
|
+
.hero { display: grid; min-block-size: 100svh; }
|
|
240
|
+
/* Both in the same cell: the picture fills the screen, the words sit over it. */
|
|
241
|
+
.hero > * { grid-area: 1 / 1; }
|
|
242
|
+
.hero :global(.shot img) { block-size: 100%; object-fit: cover; }
|
|
243
|
+
/* Contrast that holds over every part of the photograph, not just this part. */
|
|
244
|
+
.hero__scrim { background: linear-gradient(to right, #0009 0 45%, transparent 75%); }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
and one that has no photograph in the first screen at all, where the type is the
|
|
248
|
+
picture and the measuring is all in the type scale:
|
|
249
|
+
|
|
250
|
+
```css
|
|
251
|
+
.hero { display: grid; align-content: center; min-block-size: 88svh; }
|
|
252
|
+
.hero h1 { font-size: clamp(2.5rem, 1rem + 7vw, 8rem); text-wrap: balance; }
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**A photograph is cropped by its frame, never squeezed into it.** The frame is
|
|
256
|
+
chosen from the picture's own proportions rather than the other way round —
|
|
257
|
+
`shape()` before `<Shot>`, and `focus` where a face is involved. A stretched
|
|
258
|
+
photograph is the one image fault a client always notices and never has words
|
|
259
|
+
for, and it is measured rather than eyeballed. See
|
|
260
|
+
[photos.md](photos.md).
|
|
261
|
+
|
|
262
|
+
**The other end of the range is a 27-inch monitor, and it is the one nobody
|
|
263
|
+
opens.** Every rule above is about fitting into a screen that is too small; this
|
|
264
|
+
is the opposite fault and it ships more often, because the page is built at 1440
|
|
265
|
+
and never widened. Measured on a real build at 2560×1440: the content spanned
|
|
266
|
+
**38% of the width**, the full-height hero was **78% empty**, and the headline
|
|
267
|
+
was 48px — exactly what it had been on a laptop.
|
|
268
|
+
|
|
269
|
+
The principle is that **on a big screen the design gets bigger, it does not get
|
|
270
|
+
more centred.** Space is not a layout. Four patterns do nearly all of it:
|
|
271
|
+
|
|
272
|
+
```css
|
|
273
|
+
/* 1. The shell grows with the screen; the measure does not. A 1200px cap is
|
|
274
|
+
47% of a 2560px monitor, and everything inside it looks stranded. */
|
|
275
|
+
.shell { inline-size: min(92rem, 92vw); margin-inline: auto; }
|
|
276
|
+
.text > * { max-inline-size: 34rem; } /* ~65ch, so lines stay readable */
|
|
277
|
+
|
|
278
|
+
/* 2. Type scales, with a ceiling. A vw term alone is unbounded; clamp() gives
|
|
279
|
+
it a floor and a cap and is the whole answer for headings. */
|
|
280
|
+
h1 { font-size: clamp(2.25rem, 1.2rem + 3.4vw, 5rem); }
|
|
281
|
+
.lead { font-size: clamp(1rem, 0.9rem + 0.5vw, 1.5rem); }
|
|
282
|
+
|
|
283
|
+
/* 3. Media is a fraction of its column, never a fixed card. A 240px photograph
|
|
284
|
+
on a 2560px screen is a stamp. */
|
|
285
|
+
.hero__media :global(.shot) { inline-size: min(100%, 32rem); }
|
|
286
|
+
|
|
287
|
+
/* 4. Where there is genuinely nothing more to show, stop claiming the screen:
|
|
288
|
+
a band that is 80% empty is worse than a band that ends. */
|
|
289
|
+
.hero { min-block-size: min(100svh, 56rem); }
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Gaps and padding scale the same way — `gap: clamp(2rem, 4vw, 6rem)` — so the
|
|
293
|
+
composition breathes with the window instead of holding a 1440px pose inside a
|
|
294
|
+
2560px frame.
|
|
295
|
+
|
|
296
|
+
**And it is every band, not only the hero.** Two faults, both measured at 1920:
|
|
297
|
+
|
|
298
|
+
- **A lopsided band** — content against one edge and a void beside it. Nobody
|
|
299
|
+
chooses that; it is a two-column grid whose second column ended up empty, or a
|
|
300
|
+
row that stopped short. Measured on a real build: 814px of air on one side of
|
|
301
|
+
a section and 336 on the other.
|
|
302
|
+
- **A form floating in a third of the screen.** A column of prose may be narrow —
|
|
303
|
+
that is what a measure is for, and a long block of questions reads as a
|
|
304
|
+
document — but the contact band is the block the whole page exists to deliver
|
|
305
|
+
somebody to, and a 600px form centred in 1920px of colour with two thirds of
|
|
306
|
+
the screen empty reads as a page that ran out of ideas. Measured on a real
|
|
307
|
+
build: 31%. Give it something to stand beside — the contact details, a
|
|
308
|
+
photograph, the questions — or let the fields use the width.
|
|
309
|
+
|
|
310
|
+
```css
|
|
311
|
+
/* The form band, using the space it is in. */
|
|
312
|
+
.contact__inner {
|
|
313
|
+
display: grid;
|
|
314
|
+
gap: clamp(2rem, 4vw, 5rem);
|
|
315
|
+
grid-template-columns: 1fr;
|
|
316
|
+
}
|
|
317
|
+
@media (min-width: 60rem) {
|
|
318
|
+
/* Form beside something that belongs next to it, not beside nothing. */
|
|
319
|
+
.contact__inner { grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); }
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`fl-check` measures the hero at 2560×1440 — how much of the width its ink spans,
|
|
324
|
+
how much of a full-height hero is empty, whether the headline grew at all — and
|
|
325
|
+
every band at 1920 for the two faults above.
|
|
326
|
+
|
|
327
|
+
**Measure in `svh`, never `vh`.** On a phone `100vh` is taller than the screen —
|
|
328
|
+
it counts the space the browser chrome is currently occupying. The difference is
|
|
329
|
+
about the height of a button, which is why the button is the thing that falls
|
|
330
|
+
off.
|
|
331
|
+
|
|
332
|
+
**No orphans, and do not fix them by counting.** A row that leaves one chip
|
|
333
|
+
alone on its last line reads as a mistake rather than a rhythm, and a heading
|
|
334
|
+
whose last line is one word reads as a typo.
|
|
335
|
+
|
|
336
|
+
The tempting fix — drop an item, tighten the gap, shorten a label — is
|
|
337
|
+
whack-a-mole: free wrap packs by measured width, so removing the ninth chip
|
|
338
|
+
moves the orphan from 1440px to 1280px and it returns the first time a label
|
|
339
|
+
gets longer. Free wrap is the wrong tool for a set whose shape matters. Use
|
|
340
|
+
fixed columns that reflow instead — the same `4 → 2 → 1` the Motion section asks
|
|
341
|
+
of every row — and keep the set even, so it divides exactly at every width:
|
|
342
|
+
|
|
343
|
+
```css
|
|
344
|
+
.chips { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
|
345
|
+
.chip { justify-self: start; } /* pills keep their own width */
|
|
346
|
+
@media (min-width: 60rem) { .chips { grid-template-columns: repeat(4, 1fr); } }
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Centring the row is the fallback when the set genuinely cannot be made even: a
|
|
350
|
+
short last line reads as deliberate when it is centred and as a bug when it is
|
|
351
|
+
flush left. For prose, `text-wrap: balance` on headings and `pretty` on body
|
|
352
|
+
copy.
|
|
353
|
+
|
|
354
|
+
**In-page links scroll, they do not jump, and they land on content.** A bare
|
|
355
|
+
`href="#work"` teleports, which on a long page reads as a reload; and the
|
|
356
|
+
target's border box going to the top of the screen means arriving at a section's
|
|
357
|
+
top padding, with the heading still below the fold. `scroll-behavior: smooth`
|
|
358
|
+
belongs on `html` under `prefers-reduced-motion: no-preference`, and every
|
|
359
|
+
anchor target sets its own `scroll-margin-block-start` — positive to keep air
|
|
360
|
+
above a panel whose edge is drawn, negative to pull past padding that carries no
|
|
361
|
+
meaning:
|
|
362
|
+
|
|
363
|
+
```css
|
|
364
|
+
#work { scroll-margin-block-start: calc(2.5rem - var(--band)); }
|
|
365
|
+
#contact { scroll-margin-block-start: 2rem; }
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Check it by clicking, not by reading the CSS: what has to be on screen after the
|
|
369
|
+
scroll settles is the heading the link promised.
|
|
370
|
+
|
|
371
|
+
None of this is a matter of taste, so do not settle it by looking. Ask the page,
|
|
372
|
+
with the dev server running:
|
|
373
|
+
|
|
374
|
+
```js
|
|
375
|
+
const { chromium } = require('playwright');
|
|
376
|
+
const b = await chromium.launch();
|
|
377
|
+
for (const [w, h] of [[1440, 900], [1280, 800], [393, 852]]) {
|
|
378
|
+
const p = await b.newPage({ viewport: { width: w, height: h } });
|
|
379
|
+
await p.goto('http://localhost:4321/', { waitUntil: 'networkidle' });
|
|
380
|
+
const m = await p.evaluate(() => ({
|
|
381
|
+
vh: innerHeight,
|
|
382
|
+
hero: Math.round(document.querySelector('.hero').getBoundingClientRect().bottom),
|
|
383
|
+
cta: Math.round(document.querySelector('.hero .btn').getBoundingClientRect().bottom),
|
|
384
|
+
orphans: [...document.querySelectorAll('[data-row]')].filter((row) => {
|
|
385
|
+
const tops = [...row.children].map((c) => Math.round(c.getBoundingClientRect().top));
|
|
386
|
+
const perLine = [...new Set(tops)].map((t) => tops.filter((x) => x === t).length);
|
|
387
|
+
// One item alone on the last line is only an orphan when the other lines
|
|
388
|
+
// hold more. A single-column list is a list, not a broken row — miss that
|
|
389
|
+
// and the check cries wolf at every narrow width.
|
|
390
|
+
return perLine.length > 1 && perLine.at(-1) === 1 && Math.max(...perLine) > 1;
|
|
391
|
+
}).length,
|
|
392
|
+
}));
|
|
393
|
+
console.log(`${w}×${h}`,
|
|
394
|
+
m.hero <= m.vh ? 'hero fits' : `HERO OVERFLOWS ${m.hero - m.vh}px`,
|
|
395
|
+
m.cta <= m.vh ? 'cta visible' : 'CTA BELOW THE FOLD',
|
|
396
|
+
m.orphans ? `ORPHAN in ${m.orphans} row(s)` : '');
|
|
397
|
+
await p.close();
|
|
398
|
+
}
|
|
399
|
+
await b.close();
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Three viewports catch the hero. Orphans need a sweep — they appear at widths
|
|
403
|
+
nobody thinks to open, so step the width from 320 to 1600 in twenties and let the
|
|
404
|
+
same check run at each. Mark the rows you want watched with `data-row` and this
|
|
405
|
+
stays honest as the copy changes — an orphan is created by a longer word, not by a code change, so it
|
|
406
|
+
comes back on its own after a round of revisions.
|
|
407
|
+
|
|
408
|
+
**Screenshotting a page with scroll reveals needs a walk, not a jump.** Scroll
|
|
409
|
+
straight to the bottom and back and the `IntersectionObserver` never sees the
|
|
410
|
+
middle of the page: the full-page screenshot then shows empty bands, and half an
|
|
411
|
+
hour goes into debugging a layout that was never broken. Step down half a
|
|
412
|
+
viewport at a time with a pause, then shoot.
|
|
413
|
+
|
|
414
|
+
## Performance
|
|
415
|
+
|
|
416
|
+
- Images through `astro:assets`, never a raw `<img src="/photo.jpg">` out of
|
|
417
|
+
`public/`. Keep `src/assets/` at web scale — Astro emits the source file
|
|
418
|
+
alongside the generated webp variants, so a 26MB PNG ships 26MB whether or not
|
|
419
|
+
a browser requests it.
|
|
420
|
+
- The first contentful image gets `eager` / `fetchpriority="high"`. Everything
|
|
421
|
+
else is lazy.
|
|
422
|
+
- Two webfont families at most, and preconnect to the font host. A third family
|
|
423
|
+
is nearly always the reason mobile Lighthouse is at 84.
|
|
424
|
+
- No client JS on a page that does not need it. Islands are `client:visible`,
|
|
425
|
+
not `client:load`.
|
|
426
|
+
- Target: Lighthouse ≥ 95 desktop, ≥ 90 mobile, measured on the deployed site.
|
|
427
|
+
|
|
428
|
+
## Accessibility floor
|
|
429
|
+
|
|
430
|
+
Not a feature, a floor. Visible keyboard focus. Real alt text, not file names —
|
|
431
|
+
a photo with no alt is announced by its filename, and these sites are mostly
|
|
432
|
+
photographs. Skip link to the main content. Colour contrast checked for the
|
|
433
|
+
token that carries text, which is usually a step darker than the one that fills
|
|
434
|
+
a button; a component that puts words on the fill token is a contrast bug.
|
|
435
|
+
`<html lang>` set from the locale.
|
|
436
|
+
|
|
437
|
+
## The loop
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
npm run dev # http://localhost:4321
|
|
441
|
+
npm run check # 0 errors / 0 warnings / 0 hints
|
|
442
|
+
npm run build
|
|
443
|
+
npx @jtakeit/astro catalogue # jtk/catalogue.json, checked against dist/ both ways
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
The form posts to `/api/lead`, which the platform serves beside the site: locally
|
|
447
|
+
it answers 404, and that is not the form being broken. It works on the platform's
|
|
448
|
+
preview the moment the site is attached and built.
|
|
449
|
+
|
|
450
|
+
`npm run check` says the code is sound; it says nothing about whether the page
|
|
451
|
+
composes — the first screen at 1440, 1280 and 393, orphans swept from 320 to
|
|
452
|
+
1600, anchors landing on their headings, placeholder frames, alt text, stretched
|
|
453
|
+
photographs, the page with JavaScript switched off. The sections above are the
|
|
454
|
+
rules; measure the built page against them before anybody is asked to look.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# A site in more than one language
|
|
2
|
+
|
|
3
|
+
Two language versions of the same site, in one repository, edited by one owner
|
|
4
|
+
in one admin.
|
|
5
|
+
|
|
6
|
+
**Only when it was asked for.** A second language is a second site to keep
|
|
7
|
+
written — every page, every post, every footer, for ever. It is a commitment,
|
|
8
|
+
not a feature, and the bar is a sentence in the brief that names the language.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Declare the languages once
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// src/content/blocks.ts
|
|
16
|
+
export const LOCALE = 'en'; // the one the site is written in
|
|
17
|
+
export const LOCALES: string[] = ['ru']; // and every other one it has
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Both, in one place, because **a site declares every language it has where it
|
|
21
|
+
declares everything else.** `LOCALE` is what `META.lang` and `<html lang>` are
|
|
22
|
+
— the copy file reads it rather than repeating it — and it is what the admin
|
|
23
|
+
calls the site's own language on every screen that names one. It used to live
|
|
24
|
+
only in the admin's record of the site, set when the site was created and
|
|
25
|
+
changeable by nobody, and a site written in English could carry a record saying
|
|
26
|
+
Ukrainian.
|
|
27
|
+
|
|
28
|
+
Everything else derives from those two lines: where an entry's address goes,
|
|
29
|
+
which languages the admin offers, which translations it reports as missing. A
|
|
30
|
+
fact repeated in three places is a fact that is eventually three different facts.
|
|
31
|
+
|
|
32
|
+
## The address pattern is fixed
|
|
33
|
+
|
|
34
|
+
**The language goes first, and everything after it is the same:**
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
/prices /ru/prices
|
|
38
|
+
/blog/healing /ru/blog/healing
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Not `/blog/ru/...`, which reads as a section of the blog called "ru" — to a
|
|
42
|
+
person and to a crawler. Not `/ru/zhurnal/...` either, however much better that
|
|
43
|
+
reads in Russian: a free choice there is a place where two sites built from this
|
|
44
|
+
kit come out different, and then the studio keeps two conventions for the sake
|
|
45
|
+
of one segment nobody searches for.
|
|
46
|
+
|
|
47
|
+
**And not a translated slug.** `/ru/blog/healing`, not `/ru/blog/zazhivlenie`,
|
|
48
|
+
even for a post written in Russian. The whole address being the same is what
|
|
49
|
+
pairs the two languages — nothing is declared, so nothing can be misspelt, and
|
|
50
|
+
`fl-check` answers "are these the same page" by looking rather than by trusting.
|
|
51
|
+
The price is one segment of one address reading in the site's own language.
|
|
52
|
+
|
|
53
|
+
For an entry the admin builds the address from this pattern, so it is not a
|
|
54
|
+
convention you can drift from. For a page you write the route yourself, and
|
|
55
|
+
`fl-check` holds you to it.
|
|
56
|
+
|
|
57
|
+
## The shape
|
|
58
|
+
|
|
59
|
+
A language version of a page is **a page**. It has its own route and its own
|
|
60
|
+
content document, and it says one thing about itself:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "path": "/prices" }
|
|
64
|
+
{ "path": "/de/prices", "locale": "de" }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`locale` is absent for the site's own language, which `jtk/site.json`
|
|
68
|
+
already says.
|
|
69
|
+
|
|
70
|
+
**The address is the same in every language, with the language in front of it.**
|
|
71
|
+
`/de/prices`, never `/de/preise` — and `/de/blog/healing` for a post written in
|
|
72
|
+
German. That is the whole of what pairs them: nothing is declared, so nothing
|
|
73
|
+
can be misspelt.
|
|
74
|
+
|
|
75
|
+
There used to be a `group` key the languages of one page shared, which let each
|
|
76
|
+
language have its own words. It is gone, and `fl-check` refuses a document that
|
|
77
|
+
still carries one. The reason is worth knowing, because it is the shape of most
|
|
78
|
+
rules in this kit: `group: "work"` on one page against `group: "works"` on the
|
|
79
|
+
other is two unrelated pages, no error anywhere, and a site that has quietly
|
|
80
|
+
lost its `hreflang`. Nothing could tell that apart from a page which genuinely
|
|
81
|
+
has no translation. **A rule that can be checked beats a rule that must be
|
|
82
|
+
obeyed** — and what it costs is one segment of one address reading in the site's
|
|
83
|
+
own language.
|
|
84
|
+
|
|
85
|
+
## What you write
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
src/pages/prices.astro jtk/content/prices.json
|
|
89
|
+
src/pages/de/prices.astro jtk/content/de/prices.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Two routes and two documents. They may render the same components or different
|
|
93
|
+
ones; a language version is a page, and a page is yours.
|
|
94
|
+
|
|
95
|
+
Both go in `PAGES` in `src/data/site.ts`, so both are in the sitemap.
|
|
96
|
+
|
|
97
|
+
## `hreflang`, which is the part that costs money
|
|
98
|
+
|
|
99
|
+
Without it, two language versions of one page compete with each other in search
|
|
100
|
+
and an engine picks a winner you did not choose. With it, each is offered to the
|
|
101
|
+
right reader.
|
|
102
|
+
|
|
103
|
+
It comes out of the data — never a list somebody maintains:
|
|
104
|
+
|
|
105
|
+
```astro
|
|
106
|
+
---
|
|
107
|
+
import { readPage } from '../lib/page';
|
|
108
|
+
const prices = readPage('/prices');
|
|
109
|
+
---
|
|
110
|
+
<Layout title={prices.seo.title} description={prices.seo.description} alternates={prices.alternates}>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`alternates` is every language of this page, found by taking the language off
|
|
114
|
+
the front of the address and looking for the same address under every other one.
|
|
115
|
+
A page that exists in one language answers with nothing and `Layout` emits
|
|
116
|
+
nothing — a `hreflang` set of one is noise.
|
|
117
|
+
|
|
118
|
+
The switcher on the page is the same list, rendered:
|
|
119
|
+
|
|
120
|
+
```astro
|
|
121
|
+
{prices.alternates.map((other) => <a href={other.path}>{other.locale || META.lang}</a>)}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## The text that is on every page
|
|
125
|
+
|
|
126
|
+
Per language, because it is text: `jtk/shared.json` for the site's own and
|
|
127
|
+
`jtk/shared.de.json` beside it. Read the one the page is in:
|
|
128
|
+
|
|
129
|
+
```astro
|
|
130
|
+
const chrome = readShared('de');
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The annotation is unchanged — `shared:blocks[0].note` — because a shared
|
|
134
|
+
document is already one language. The admin resolves it against the shared
|
|
135
|
+
document of **the page's** language, so a footer edited under a German page is
|
|
136
|
+
the German footer.
|
|
137
|
+
|
|
138
|
+
## Entries of a collection
|
|
139
|
+
|
|
140
|
+
Two languages of one post are **two entries at the same address**, one with the
|
|
141
|
+
language in front of it. They are separate on purpose: different words, of
|
|
142
|
+
different lengths, published on different days, and one may not exist yet. What
|
|
143
|
+
makes them one thing is the slug, which they share.
|
|
144
|
+
|
|
145
|
+
So a post written in German lives at `/de/blog/healing` and not at
|
|
146
|
+
`/de/blog/heilung`. The admin shows one row with the languages it exists in and
|
|
147
|
+
the ones it does not; writing the missing one is a button on that row, and it
|
|
148
|
+
does not ask for an address, because there is nothing to decide.
|
|
149
|
+
|
|
150
|
+
The listing for each language shows its own — `listed('blog')` filtered by the
|
|
151
|
+
locale of the page rendering it.
|
|
152
|
+
|
|
153
|
+
A **page** in a language it does not have is not the admin's to make: a page is
|
|
154
|
+
a file you wrote, so it reports the gap and you fill it.
|
|
155
|
+
|
|
156
|
+
## The switcher must never rewrite the address
|
|
157
|
+
|
|
158
|
+
Build it from `alternates`, which is the list of languages that **exist**:
|
|
159
|
+
|
|
160
|
+
```astro
|
|
161
|
+
{prices.alternates.map((other) => <a href={other.path}>{other.locale || META.lang}</a>)}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Never by swapping a prefix into the current URL. That is the shape that gives
|
|
165
|
+
somebody a 404 the first time a page has no translation — and on a blog, where
|
|
166
|
+
most posts will only ever be written once, that is most of them.
|
|
167
|
+
|
|
168
|
+
## The catalogue is not translated
|
|
169
|
+
|
|
170
|
+
A field's label is what the person *editing* reads, and it has never been a
|
|
171
|
+
property of the site's language — that was a coincidence, because a site had one
|
|
172
|
+
language and the owner spoke it.
|
|
173
|
+
|
|
174
|
+
**Write the labels in the owner's language, not the site's.** For a bilingual
|
|
175
|
+
site they stay in one language, which is correct: the owner is one person.
|
|
176
|
+
|
|
177
|
+
## What does not change
|
|
178
|
+
|
|
179
|
+
Annotations, the bridge, the build, the edge, the catalogue's block types,
|
|
180
|
+
drafts, versions and publishing. A page is already one language, so
|
|
181
|
+
`blocks[0].title` is unambiguous and everything that reads it is untouched. Two
|
|
182
|
+
languages is one repository and one `astro build`.
|