@jtakeit/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/jtk.mjs +41 -0
  4. package/docs/booking.md +164 -0
  5. package/docs/catalogue.md +459 -0
  6. package/docs/collections.md +249 -0
  7. package/docs/css.md +86 -0
  8. package/docs/gallery.md +127 -0
  9. package/docs/hero-motion.md +189 -0
  10. package/docs/kit.md +454 -0
  11. package/docs/languages.md +182 -0
  12. package/docs/lead-form.md +109 -0
  13. package/docs/pages.md +193 -0
  14. package/docs/photos.md +314 -0
  15. package/docs/scaffold.md +75 -0
  16. package/docs/shapes.md +140 -0
  17. package/docs/surface.md +187 -0
  18. package/lib/catalogue.mjs +1678 -0
  19. package/lib/codes.mjs +171 -0
  20. package/lib/create.mjs +282 -0
  21. package/package.json +16 -0
  22. package/template/astro.config.mjs +84 -0
  23. package/template/figures.mjs +122 -0
  24. package/template/gitignore +16 -0
  25. package/template/jtakeit-meta.mjs +112 -0
  26. package/template/jtk/content/index.json +38 -0
  27. package/template/jtk/design.json +24 -0
  28. package/template/markdown.mjs +36 -0
  29. package/template/package-lock.json +5320 -0
  30. package/template/package.json +26 -0
  31. package/template/specimens.mjs +46 -0
  32. package/template/src/components/Blocks.astro +151 -0
  33. package/template/src/components/BookingForm.astro +506 -0
  34. package/template/src/components/Clip.astro +155 -0
  35. package/template/src/components/Hero.astro +66 -0
  36. package/template/src/components/LeadForm.astro +347 -0
  37. package/template/src/components/OpeningHours.astro +69 -0
  38. package/template/src/components/Pile.astro +185 -0
  39. package/template/src/components/Shot.astro +472 -0
  40. package/template/src/components/gallery/Gallery.astro +381 -0
  41. package/template/src/components/gallery/galleries.ts +139 -0
  42. package/template/src/components/motion/HeroField.astro +520 -0
  43. package/template/src/components/motion/fields.ts +430 -0
  44. package/template/src/components/surface/Pattern.astro +278 -0
  45. package/template/src/components/surface/patterns.ts +187 -0
  46. package/template/src/content/blocks.ts +758 -0
  47. package/template/src/content.config.ts +19 -0
  48. package/template/src/copy/LOCALE.ts +324 -0
  49. package/template/src/data/site.ts +137 -0
  50. package/template/src/layouts/Layout.astro +282 -0
  51. package/template/src/lib/alive.ts +49 -0
  52. package/template/src/lib/entries.ts +106 -0
  53. package/template/src/lib/entryLoader.ts +315 -0
  54. package/template/src/lib/noise.ts +26 -0
  55. package/template/src/lib/page.ts +287 -0
  56. package/template/src/lib/photos.ts +168 -0
  57. package/template/src/lib/under.ts +32 -0
  58. package/template/src/lib/uploads.ts +85 -0
  59. package/template/src/pages/[...entry].astro +207 -0
  60. package/template/src/pages/[...feed].xml.ts +64 -0
  61. package/template/src/pages/index.astro +90 -0
  62. package/template/src/pages/llms.txt.ts +50 -0
  63. package/template/src/pages/privacy.astro +59 -0
  64. package/template/src/pages/robots.txt.ts +21 -0
  65. package/template/src/pages/sitemap.xml.ts +50 -0
  66. package/template/src/styles/global.css +411 -0
  67. package/template/src/styles/surface.css +375 -0
  68. package/template/tsconfig.json +5 -0
@@ -0,0 +1,381 @@
1
+ ---
2
+ /**
3
+ * The work, shown.
4
+ *
5
+ * Six arrangements, and the choice is made by the material: how much work there
6
+ * is, and whether it has an order. What each one is for is in ./galleries.ts
7
+ * and in the table in `docs/gallery.md` of @jtakeit/astro.
8
+ *
9
+ * <Gallery arrangement="wall" items={WORK} />
10
+ * <Gallery arrangement="index" items={[{ name: 'sleeve', caption: 'Forearm, two sittings' }]} />
11
+ *
12
+ * ── one of them moves, and that is a bounded exception ──────────────────────
13
+ *
14
+ * The kit's Motion rule is that nothing moves while it is being read. A wall of
15
+ * photographs is *looked at* rather than read, which is what makes the
16
+ * exception arguable at all — and it is only allowed on three conditions, all
17
+ * of them here rather than in a comment somewhere:
18
+ *
19
+ * · it stops on hover and on keyboard focus, so it is still whenever anybody
20
+ * is actually looking at one frame;
21
+ * · reduced motion gets the wall laid out still and in full — the finished
22
+ * thing, not a shorter one;
23
+ * · nothing readable is inside it and nothing is positioned against it.
24
+ *
25
+ * It also declares itself: `data-motion="gallery"` marks the region as one
26
+ * thing that moves forever, and `fl-check` counts those regions rather than
27
+ * counting animated elements. A page whose hero already moves and whose wall
28
+ * moves too is two, and two is the number the check argues about.
29
+ */
30
+ import Shot from '../Shot.astro';
31
+ import { GALLERIES, asPieces, deal, type Arrangement, type Piece } from './galleries';
32
+
33
+ interface Props {
34
+ /**
35
+ * ragged · wall · contact · strip · spread · index.
36
+ * Required: there is no default arrangement, because the right one is decided
37
+ * by how much work there is and a default would decide it by habit.
38
+ */
39
+ arrangement: Arrangement;
40
+ /** File names in src/assets, or `{ name, caption }` where the words matter. */
41
+ items: readonly (string | Piece)[];
42
+ /** Overrides the arrangement's own column count. */
43
+ columns?: number;
44
+ /**
45
+ * `wall` only: how long one full cycle takes. Long. A wall that completes in
46
+ * thirty seconds is a wall somebody is watching instead of reading the page.
47
+ */
48
+ cycle?: string;
49
+ /** `wall` only: how tall the window is. About two photographs. */
50
+ window?: string;
51
+ class?: string;
52
+ }
53
+
54
+ const { arrangement, items, columns, cycle, window: windowHeight, class: className } = Astro.props;
55
+
56
+ const recipe = GALLERIES[arrangement];
57
+ if (!recipe) {
58
+ throw new Error(
59
+ `<Gallery arrangement="${arrangement}"> is not one of: ${Object.keys(GALLERIES).join(', ')}. ` +
60
+ 'See references/gallery.md — the arrangement is chosen from the material, not from taste.',
61
+ );
62
+ }
63
+
64
+ const pieces = asPieces(items);
65
+
66
+ /*
67
+ * How much work there is decides the arrangement, so a count outside what an
68
+ * arrangement is for is worth saying out loud at the moment it can still be
69
+ * changed. It does not fail the build: a `spread` of twelve is a decision
70
+ * somebody may have made on purpose, and a warning in front of the person who
71
+ * chose it is enough.
72
+ */
73
+ const [floor, ceiling] = recipe.works;
74
+ if (pieces.length < floor || pieces.length > ceiling) {
75
+ console.warn(
76
+ `[gallery] ${arrangement} is for ${floor}–${ceiling} pieces and it has been given ${pieces.length}. ` +
77
+ (pieces.length < floor
78
+ ? 'Below that it reads as a business that has done very little — see gallery.md for the arrangement that suits a short list.'
79
+ : 'Above that it stops reading as one thing.'),
80
+ );
81
+ }
82
+
83
+ if (arrangement === 'index' && !pieces.some((p) => p.caption)) {
84
+ throw new Error(
85
+ '<Gallery arrangement="index"> is a list of work *with words beside it*, and none of these items ' +
86
+ 'has a caption. Give them captions, or use `ragged` — an index with nothing to read is a worse ragged.',
87
+ );
88
+ }
89
+
90
+ const cols = Math.max(1, columns ?? recipe.columns);
91
+ const columnsOf = arrangement === 'ragged' || arrangement === 'wall' ? deal(pieces, cols) : [];
92
+
93
+ const vars = [
94
+ `--gallery-columns:${cols}`,
95
+ cycle ? `--gallery-cycle:${cycle}` : '',
96
+ windowHeight ? `--gallery-window:${windowHeight}` : '',
97
+ ]
98
+ .filter(Boolean)
99
+ .join(';');
100
+
101
+ /** The wall runs its outer columns one way and its inner ones the other. */
102
+ const drift = (n: number) => (n % 2 === 1 ? 'up' : 'down');
103
+
104
+ const shotWidth = arrangement === 'contact' ? 420 : arrangement === 'spread' ? 1400 : 700;
105
+ const sizes =
106
+ arrangement === 'spread'
107
+ ? '(min-width: 62rem) 70vw, 92vw'
108
+ : arrangement === 'contact'
109
+ ? '(min-width: 62rem) 16vw, 30vw'
110
+ : '(min-width: 62rem) 25vw, 44vw';
111
+ ---
112
+
113
+ <div
114
+ class:list={['gallery', `gallery--${arrangement}`, className]}
115
+ data-arrangement={arrangement}
116
+ data-motion={recipe.moves ? 'gallery' : undefined}
117
+ style={vars}
118
+ >
119
+ {
120
+ arrangement === 'wall' || arrangement === 'ragged' ? (
121
+ columnsOf.map((column, n) => (
122
+ <div class="gallery__col" data-drift={recipe.moves ? drift(n) : undefined}>
123
+ <div class="gallery__track">
124
+ {/*
125
+ A moving column is rendered twice and travels exactly half its own
126
+ height, which is what makes the loop seamless: the halfway point
127
+ is pixel-identical to the start. The second pass is the same
128
+ pictures saying nothing new, so it is hidden from a screen reader
129
+ rather than read out again.
130
+ */}
131
+ {(recipe.moves ? [0, 1] : [0]).map((pass) =>
132
+ column.map((piece) => (
133
+ <figure class="gallery__frame" aria-hidden={pass === 1 ? 'true' : undefined} data-pass={pass}>
134
+ <Shot
135
+ name={piece.name}
136
+ alt={piece.alt}
137
+ path={pass === 0 ? piece.path : undefined}
138
+ decorative={pass === 1}
139
+ width={shotWidth}
140
+ sizes={sizes}
141
+ />
142
+ </figure>
143
+ )),
144
+ )}
145
+ </div>
146
+ </div>
147
+ ))
148
+ ) : (
149
+ pieces.map((piece) => (
150
+ <figure class="gallery__frame">
151
+ <Shot
152
+ name={piece.name}
153
+ alt={piece.alt}
154
+ path={piece.path}
155
+ width={shotWidth}
156
+ sizes={sizes}
157
+ ratio={arrangement === 'contact' ? '1 / 1' : undefined}
158
+ />
159
+ {/* A caption comes from a `list` of rows rather than from a gallery
160
+ field — see the `index` arrangement in references/gallery.md — and
161
+ it is content, so it carries its own path. */}
162
+ {piece.caption && (
163
+ <figcaption class="gallery__caption" data-jtk-path={piece.captionPath}>
164
+ {piece.caption}
165
+ </figcaption>
166
+ )}
167
+ </figure>
168
+ ))
169
+ )
170
+ }
171
+ </div>
172
+
173
+ <style>
174
+ .gallery {
175
+ --gap: clamp(0.75rem, 1.6vw, 1.25rem);
176
+ margin-block-start: clamp(2rem, 4vw, 3.5rem);
177
+ }
178
+
179
+ .gallery__frame {
180
+ margin: 0;
181
+ }
182
+
183
+ .gallery__caption {
184
+ padding-block-start: 0.5rem;
185
+ font-size: 0.875rem;
186
+ line-height: 1.5;
187
+ color: var(--ink-quiet);
188
+ }
189
+
190
+ /* ── ragged: columns, still, nothing cropped ───────────────────────────── */
191
+
192
+ .gallery--ragged,
193
+ .gallery--wall {
194
+ display: grid;
195
+ grid-template-columns: repeat(2, minmax(0, 1fr));
196
+ gap: var(--gap);
197
+ }
198
+
199
+ @media (min-width: 62rem) {
200
+ .gallery--ragged,
201
+ .gallery--wall {
202
+ grid-template-columns: repeat(var(--gallery-columns, 3), minmax(0, 1fr));
203
+ }
204
+ }
205
+
206
+ .gallery__track {
207
+ display: grid;
208
+ align-content: start;
209
+ gap: var(--gap);
210
+ }
211
+
212
+ /*
213
+ * Below 62rem the last column is dropped rather than squeezed. Each column is
214
+ * a fair sample — the pieces are dealt, not sliced — so what goes is a third
215
+ * of the wall and not a period of the work.
216
+ */
217
+ @media (max-width: 61.99rem) {
218
+ .gallery--ragged .gallery__col:nth-child(n + 3),
219
+ .gallery--wall .gallery__col:nth-child(n + 3) {
220
+ display: none;
221
+ }
222
+ }
223
+
224
+ /* ── wall: the one that moves ──────────────────────────────────────────── */
225
+
226
+ /*
227
+ * A window about two photographs tall. The block is a view onto the work
228
+ * rather than the work laid end to end, and that is what stops a body of
229
+ * thirty-seven from becoming most of the page.
230
+ */
231
+ .gallery--wall {
232
+ block-size: var(--gallery-window, min(86svh, 52rem));
233
+ overflow: clip;
234
+ }
235
+
236
+ .gallery--wall .gallery__col {
237
+ min-block-size: 0;
238
+ overflow: clip;
239
+ }
240
+
241
+ /*
242
+ * Half the track's own height, forever. The track is the same pictures twice,
243
+ * so half of it is exactly one pass — the one place a percentage is the right
244
+ * unit, because the distance is a property of the track and not of the
245
+ * screen.
246
+ */
247
+ @keyframes gallery-down {
248
+ from {
249
+ transform: translate3d(0, -50%, 0);
250
+ }
251
+
252
+ to {
253
+ transform: translate3d(0, 0, 0);
254
+ }
255
+ }
256
+
257
+ @keyframes gallery-up {
258
+ from {
259
+ transform: translate3d(0, 0, 0);
260
+ }
261
+
262
+ to {
263
+ transform: translate3d(0, -50%, 0);
264
+ }
265
+ }
266
+
267
+ /*
268
+ * Still whenever somebody is actually looking at one frame — and it is an
269
+ * inherited custom property rather than a `:hover` rule on the track, which
270
+ * is the version that does not work.
271
+ *
272
+ * `animation: … infinite` is a shorthand, so it resets `animation-play-state`
273
+ * to `running`; a `.gallery:hover .gallery__track` rule is *less* specific
274
+ * than the one carrying that shorthand, so the pause loses and the wall never
275
+ * stops. It reads as working — the wall drifts, the page looks right — and
276
+ * the one condition that buys this component its exception is quietly absent.
277
+ *
278
+ * A custom property set on the hovered ancestor inherits down and is read
279
+ * after the shorthand in the same declaration, so specificity never enters
280
+ * into it.
281
+ */
282
+ @media (prefers-reduced-motion: no-preference) {
283
+ .gallery--wall .gallery__col[data-drift='down'] .gallery__track {
284
+ animation: gallery-down var(--gallery-cycle, 150s) linear infinite;
285
+ animation-play-state: var(--gallery-play, running);
286
+ will-change: transform;
287
+ }
288
+
289
+ .gallery--wall .gallery__col[data-drift='up'] .gallery__track {
290
+ animation: gallery-up var(--gallery-cycle, 150s) linear infinite;
291
+ animation-play-state: var(--gallery-play, running);
292
+ will-change: transform;
293
+ }
294
+
295
+ .gallery--wall:hover,
296
+ .gallery--wall:focus-within {
297
+ --gallery-play: paused;
298
+ }
299
+ }
300
+
301
+ /*
302
+ * Reduced motion gets the whole wall, laid out and still: the window opens,
303
+ * the animation never starts, and the second pass — which exists only to make
304
+ * a loop seamless — is not shown, because with nothing moving it would simply
305
+ * be every photograph twice.
306
+ */
307
+ @media (prefers-reduced-motion: reduce) {
308
+ .gallery--wall {
309
+ block-size: auto;
310
+ overflow: visible;
311
+ }
312
+
313
+ .gallery--wall .gallery__frame[data-pass='1'] {
314
+ display: none;
315
+ }
316
+ }
317
+
318
+ /* ── contact: dense, uniform, many ─────────────────────────────────────── */
319
+
320
+ .gallery--contact {
321
+ display: grid;
322
+ grid-template-columns: repeat(auto-fill, minmax(min(7.5rem, 30vw), 1fr));
323
+ gap: calc(var(--gap) * 0.6);
324
+ }
325
+
326
+ /* ── strip: one row, moved by the visitor ──────────────────────────────── */
327
+
328
+ /*
329
+ * It scrolls inside its own container and never takes the page with it, which
330
+ * is the difference between a rail that is allowed and one that is not.
331
+ * Snapping is what makes a drag land on a picture rather than between two.
332
+ */
333
+ .gallery--strip {
334
+ display: grid;
335
+ grid-auto-flow: column;
336
+ grid-auto-columns: min(22rem, 78vw);
337
+ gap: var(--gap);
338
+ overflow-x: auto;
339
+ overscroll-behavior-inline: contain;
340
+ scroll-snap-type: x mandatory;
341
+ padding-block-end: 0.5rem;
342
+ }
343
+
344
+ .gallery--strip .gallery__frame {
345
+ scroll-snap-align: start;
346
+ }
347
+
348
+ /* ── spread: a few, large ──────────────────────────────────────────────── */
349
+
350
+ .gallery--spread {
351
+ display: grid;
352
+ gap: clamp(2rem, 5vw, 4rem);
353
+ }
354
+
355
+ /* ── index: rows with words ────────────────────────────────────────────── */
356
+
357
+ .gallery--index {
358
+ display: grid;
359
+ gap: clamp(1rem, 2.5vw, 2rem);
360
+ }
361
+
362
+ .gallery--index .gallery__frame {
363
+ display: grid;
364
+ grid-template-columns: minmax(0, 7rem) minmax(0, 1fr);
365
+ gap: clamp(1rem, 3vw, 2rem);
366
+ align-items: start;
367
+ padding-block-end: clamp(1rem, 2.5vw, 2rem);
368
+ border-block-end: 1px solid var(--line);
369
+ }
370
+
371
+ @media (min-width: 48rem) {
372
+ .gallery--index .gallery__frame {
373
+ grid-template-columns: minmax(0, 10rem) minmax(0, 1fr);
374
+ }
375
+ }
376
+
377
+ .gallery--index .gallery__caption {
378
+ padding-block-start: 0;
379
+ font-size: 1rem;
380
+ }
381
+ </style>
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The ways a body of work can be shown.
3
+ *
4
+ * Data only — no DOM. `Gallery.astro` owns the markup, the motion and every
5
+ * path back to a still page; this file is what each arrangement is *for*, which
6
+ * is the part worth reading before choosing one.
7
+ *
8
+ * ── the choice is made by the material, not by taste ────────────────────────
9
+ *
10
+ * A tattooist with eight hundred posts and thirty-seven usable frames is not
11
+ * the same problem as a baker with nine. The first needs volume to read as
12
+ * volume; the second needs nine pictures each given room, because a wall of
13
+ * nine looks like a business that has done nine jobs.
14
+ *
15
+ * So the first question is how much work there is, and the second is whether
16
+ * the work has an order. Everything else follows.
17
+ *
18
+ * ── what an arrangement may not do ─────────────────────────────────────────
19
+ *
20
+ * Whatever it looks like, all six clear the same floor: no photograph is
21
+ * stretched, nothing readable sits inside a moving surface, nothing scrolls the
22
+ * page sideways, and `prefers-reduced-motion` gets the work laid out in full
23
+ * and still — the finished thing, never a shorter one.
24
+ */
25
+
26
+ export type Arrangement = 'ragged' | 'wall' | 'contact' | 'strip' | 'spread' | 'index';
27
+
28
+ export interface Recipe {
29
+ /** One line, for the table in references/gallery.md. */
30
+ readonly what: string;
31
+ /** How many pieces it wants. Below the floor it looks thin; above the ceiling it stops reading. */
32
+ readonly works: readonly [number, number];
33
+ /** Whether it moves on its own. Only one arrangement does, and it is bounded. */
34
+ readonly moves: boolean;
35
+ /** Columns, where the arrangement has them. */
36
+ readonly columns: number;
37
+ }
38
+
39
+ export const GALLERIES: Record<Arrangement, Recipe> = {
40
+ /*
41
+ * The default, and the one to argue against rather than for. Columns of
42
+ * photographs at their own proportions, nothing cropped to a square, nothing
43
+ * moving. A calf is not the shape a forearm is, and a grid of squares takes
44
+ * the tattoo out of half of them.
45
+ */
46
+ ragged: { what: 'Still columns, every picture its own proportions.', works: [6, 24], moves: false, columns: 3 },
47
+
48
+ /*
49
+ * Volume as the argument. Columns drifting in opposite directions inside a
50
+ * window about two photographs tall, so thirty-seven frames read as one
51
+ * moving surface rather than as a very long list.
52
+ *
53
+ * It is the only arrangement here that moves on its own, and the kit argues
54
+ * against that by default — see the Motion section in the astro-kit skill.
55
+ * What buys the exception is in Gallery.astro, and it is three conditions
56
+ * rather than an opinion.
57
+ */
58
+ wall: { what: 'Columns drifting in opposite directions inside a window.', works: [18, 200], moves: true, columns: 3 },
59
+
60
+ /*
61
+ * A contact sheet: uniform cells, small, many at once, deliberately dense.
62
+ * Where the wall says "there is a lot of this" by moving, this says it by
63
+ * fitting eighty things on one screen. Wants work that survives being small —
64
+ * strong silhouettes, not fine line detail.
65
+ */
66
+ contact: { what: 'Uniform small cells, dense, many at once.', works: [24, 200], moves: false, columns: 6 },
67
+
68
+ /*
69
+ * One row, scroll-snapped, moved by the visitor. The arrangement for work
70
+ * that has an *order*: a process, a before and an after, a series. It scrolls
71
+ * inside its own container and never takes the page sideways with it.
72
+ */
73
+ strip: { what: 'One row, snapped, dragged by the visitor.', works: [3, 20], moves: false, columns: 1 },
74
+
75
+ /*
76
+ * A few, large. One piece per band, given the width it deserves. For a master
77
+ * with six outstanding photographs rather than sixty adequate ones — and the
78
+ * honest answer whenever the feed has been picked over and there is simply
79
+ * not much.
80
+ */
81
+ spread: { what: 'A few pieces, each given the full width.', works: [3, 10], moves: false, columns: 1 },
82
+
83
+ /*
84
+ * A list: a small frame, and words beside it. Where what the work *is* —
85
+ * where on the body, how long it took, what it covered — matters as much as
86
+ * how it looks, and where a caption is the argument. Needs captions; without
87
+ * them it is a bad `ragged`.
88
+ */
89
+ index: { what: 'Rows: a small frame and the words beside it.', works: [4, 30], moves: false, columns: 1 },
90
+ };
91
+
92
+ export const ARRANGEMENTS = Object.keys(GALLERIES) as Arrangement[];
93
+
94
+ /**
95
+ * One item. A name is the file in src/assets, the rest is what the page says
96
+ * about it — and `caption` is what `index` exists for.
97
+ */
98
+ export interface Piece {
99
+ /**
100
+ * A slot in `src/assets`, or a key like `media/<site>/<hash>.jpg` from the
101
+ * content document. `<Shot>` resolves both, so a gallery fed from either
102
+ * looks the same from here — `gallery()` in the copy adapter is what turns a
103
+ * content field into these.
104
+ */
105
+ readonly name: string;
106
+ readonly caption?: string;
107
+ readonly alt?: string;
108
+ /**
109
+ * `data-jtk-path` for this picture, where the gallery is fed from the content
110
+ * document. Without it the owner can see their work on the page and cannot
111
+ * touch it — `fl-catalogue` fails on exactly that, and a gallery is the
112
+ * longest list on the site to have to fix afterwards.
113
+ *
114
+ * It ends up on the `<img>` rather than on the frame around it, because what
115
+ * the admin does with it is set `src`.
116
+ */
117
+ readonly path?: string;
118
+ /** `data-jtk-path` for the caption, where the words come from content too. */
119
+ readonly captionPath?: string;
120
+ /** A clip's poster. Galleries do not render clips; `<Clip>` does. */
121
+ readonly poster?: string;
122
+ }
123
+
124
+ /** Names alone are the common case; a caption promotes an item to a row. */
125
+ export const asPieces = (items: readonly (string | Piece)[]): Piece[] =>
126
+ items.map((item) => (typeof item === 'string' ? { name: item } : item));
127
+
128
+ /**
129
+ * Deal, do not slice.
130
+ *
131
+ * Slicing a date-ordered list into columns puts this year in one column and
132
+ * three years ago in another, so a visitor reading down one column sees one
133
+ * period of the work. Dealing keeps every column mixed in subject and date,
134
+ * which is what makes any column a fair sample of the whole.
135
+ */
136
+ export const deal = <T,>(items: readonly T[], columns: number): T[][] =>
137
+ Array.from({ length: Math.max(1, columns) }, (_, n) =>
138
+ items.filter((_item, i) => i % Math.max(1, columns) === n),
139
+ );