@jtakeit/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/jtk.mjs +41 -0
  4. package/docs/booking.md +164 -0
  5. package/docs/catalogue.md +459 -0
  6. package/docs/collections.md +249 -0
  7. package/docs/css.md +86 -0
  8. package/docs/gallery.md +127 -0
  9. package/docs/hero-motion.md +189 -0
  10. package/docs/kit.md +454 -0
  11. package/docs/languages.md +182 -0
  12. package/docs/lead-form.md +109 -0
  13. package/docs/pages.md +193 -0
  14. package/docs/photos.md +314 -0
  15. package/docs/scaffold.md +75 -0
  16. package/docs/shapes.md +140 -0
  17. package/docs/surface.md +187 -0
  18. package/lib/catalogue.mjs +1678 -0
  19. package/lib/codes.mjs +171 -0
  20. package/lib/create.mjs +282 -0
  21. package/package.json +16 -0
  22. package/template/astro.config.mjs +84 -0
  23. package/template/figures.mjs +122 -0
  24. package/template/gitignore +16 -0
  25. package/template/jtakeit-meta.mjs +112 -0
  26. package/template/jtk/content/index.json +38 -0
  27. package/template/jtk/design.json +24 -0
  28. package/template/markdown.mjs +36 -0
  29. package/template/package-lock.json +5320 -0
  30. package/template/package.json +26 -0
  31. package/template/specimens.mjs +46 -0
  32. package/template/src/components/Blocks.astro +151 -0
  33. package/template/src/components/BookingForm.astro +506 -0
  34. package/template/src/components/Clip.astro +155 -0
  35. package/template/src/components/Hero.astro +66 -0
  36. package/template/src/components/LeadForm.astro +347 -0
  37. package/template/src/components/OpeningHours.astro +69 -0
  38. package/template/src/components/Pile.astro +185 -0
  39. package/template/src/components/Shot.astro +472 -0
  40. package/template/src/components/gallery/Gallery.astro +381 -0
  41. package/template/src/components/gallery/galleries.ts +139 -0
  42. package/template/src/components/motion/HeroField.astro +520 -0
  43. package/template/src/components/motion/fields.ts +430 -0
  44. package/template/src/components/surface/Pattern.astro +278 -0
  45. package/template/src/components/surface/patterns.ts +187 -0
  46. package/template/src/content/blocks.ts +758 -0
  47. package/template/src/content.config.ts +19 -0
  48. package/template/src/copy/LOCALE.ts +324 -0
  49. package/template/src/data/site.ts +137 -0
  50. package/template/src/layouts/Layout.astro +282 -0
  51. package/template/src/lib/alive.ts +49 -0
  52. package/template/src/lib/entries.ts +106 -0
  53. package/template/src/lib/entryLoader.ts +315 -0
  54. package/template/src/lib/noise.ts +26 -0
  55. package/template/src/lib/page.ts +287 -0
  56. package/template/src/lib/photos.ts +168 -0
  57. package/template/src/lib/under.ts +32 -0
  58. package/template/src/lib/uploads.ts +85 -0
  59. package/template/src/pages/[...entry].astro +207 -0
  60. package/template/src/pages/[...feed].xml.ts +64 -0
  61. package/template/src/pages/index.astro +90 -0
  62. package/template/src/pages/llms.txt.ts +50 -0
  63. package/template/src/pages/privacy.astro +59 -0
  64. package/template/src/pages/robots.txt.ts +21 -0
  65. package/template/src/pages/sitemap.xml.ts +50 -0
  66. package/template/src/styles/global.css +411 -0
  67. package/template/src/styles/surface.css +375 -0
  68. package/template/tsconfig.json +5 -0
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`.