@contentrain/astro-kit 0.30.0 → 0.32.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 (67) hide show
  1. package/README.md +25 -2
  2. package/catalog.json +3305 -83
  3. package/components/_shared/Section.astro +18 -3
  4. package/components/_shared/measured.ts +152 -0
  5. package/components/_shared/types.ts +40 -0
  6. package/components/band/Band.astro +132 -0
  7. package/components/band/fixtures.json +8 -0
  8. package/components/band/meta.json +323 -0
  9. package/components/card-grid/CardGrid.astro +7 -3
  10. package/components/card-grid/fixtures.json +2 -1
  11. package/components/card-grid/meta.json +111 -0
  12. package/components/contact-details/ContactDetails.astro +7 -2
  13. package/components/contact-details/meta.json +113 -1
  14. package/components/contact-form/ContactForm.astro +48 -12
  15. package/components/contact-form/fixtures.json +169 -9
  16. package/components/contact-form/meta.json +283 -15
  17. package/components/cta/Cta.astro +52 -12
  18. package/components/cta/fixtures.json +5 -1
  19. package/components/cta/meta.json +11 -2
  20. package/components/faq/Faq.astro +60 -17
  21. package/components/faq/fixtures.json +75 -0
  22. package/components/faq/meta.json +8 -2
  23. package/components/feature-list/FeatureList.astro +34 -6
  24. package/components/feature-list/fixtures.json +2 -1
  25. package/components/feature-list/meta.json +125 -1
  26. package/components/gallery/Gallery.astro +47 -13
  27. package/components/gallery/fixtures.json +275 -0
  28. package/components/gallery/meta.json +266 -14
  29. package/components/hero/Hero.astro +14 -8
  30. package/components/hero/fixtures.json +2 -1
  31. package/components/hero/meta.json +111 -0
  32. package/components/logo-cloud/LogoCloud.astro +85 -21
  33. package/components/logo-cloud/fixtures.json +239 -0
  34. package/components/logo-cloud/meta.json +145 -5
  35. package/components/newsletter/Newsletter.astro +5 -1
  36. package/components/newsletter/meta.json +113 -1
  37. package/components/post-card/PostCard.astro +4 -2
  38. package/components/post-card/meta.json +2 -1
  39. package/components/post-list/PostList.astro +114 -0
  40. package/components/post-list/fixtures.json +387 -0
  41. package/components/post-list/meta.json +308 -0
  42. package/components/pricing/Pricing.astro +128 -24
  43. package/components/pricing/fixtures.json +288 -4
  44. package/components/pricing/meta.json +188 -1
  45. package/components/split/Split.astro +76 -14
  46. package/components/split/fixtures.json +8 -1
  47. package/components/split/meta.json +226 -4
  48. package/components/stats/Stats.astro +42 -10
  49. package/components/stats/fixtures.json +173 -0
  50. package/components/stats/meta.json +153 -5
  51. package/components/steps/Steps.astro +36 -8
  52. package/components/steps/fixtures.json +217 -5
  53. package/components/steps/meta.json +157 -3
  54. package/components/tabs/Tabs.astro +5 -2
  55. package/components/tabs/meta.json +2 -1
  56. package/components/team/Team.astro +52 -8
  57. package/components/team/fixtures.json +217 -4
  58. package/components/team/meta.json +168 -3
  59. package/components/team/team.ts +9 -0
  60. package/components/testimonial/Testimonial.astro +71 -21
  61. package/components/testimonial/fixtures.json +73 -0
  62. package/components/testimonial/meta.json +167 -3
  63. package/mapping/classic.json +7 -1
  64. package/mapping/divi.json +21 -4
  65. package/mapping/elementor.json +33 -8
  66. package/mapping/gutenberg.json +20 -4
  67. package/package.json +2 -2
@@ -4,27 +4,279 @@
4
4
  "description": "Images in a square grid or in masonry columns, with optional captions. A click shows the image large in a native popover (no script), opens the image file, or does nothing.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["KitImage.astro", "Section.astro", "image.ts", "types.ts"],
7
+ "shared": [
8
+ "KitImage.astro",
9
+ "Section.astro",
10
+ "image.ts",
11
+ "measured.ts",
12
+ "types.ts"
13
+ ],
8
14
  "variants": {
9
- "layout": { "options": ["grid", "masonry"], "default": "grid", "description": "grid: square crops in rows. masonry: columns that keep each image's shape." },
10
- "columns": { "options": ["2", "3", "4"], "default": "3", "description": "Columns on wide screens; phones show two." },
11
- "open": { "options": ["popover", "link", "none"], "default": "popover", "description": "popover: the image opens large in place (Popover API; Escape or a click outside closes it). link: the thumbnail links to the image file (the WordPress 'link to media file'). none: images only." }
15
+ "layout": {
16
+ "options": [
17
+ "grid",
18
+ "masonry",
19
+ "row",
20
+ "mosaic"
21
+ ],
22
+ "default": "grid",
23
+ "description": "grid: equal tiles. masonry: columns keeping each image's height. row: a scroll-snap strip (a focusable region). mosaic: the first tile spans 2×2."
24
+ },
25
+ "columns": {
26
+ "options": [
27
+ "2",
28
+ "3",
29
+ "4",
30
+ "5",
31
+ "6"
32
+ ],
33
+ "default": "3",
34
+ "description": "Images per row on wide screens; two on phones."
35
+ },
36
+ "open": {
37
+ "options": [
38
+ "popover",
39
+ "link",
40
+ "none"
41
+ ],
42
+ "default": "popover",
43
+ "description": "popover: the image opens large in place (Popover API; Escape or a click outside closes it). link: the thumbnail links to the image file (the WordPress 'link to media file'). none: images only."
44
+ },
45
+ "aspect": {
46
+ "options": [
47
+ "square",
48
+ "4:3",
49
+ "16:9",
50
+ "auto"
51
+ ],
52
+ "default": "square",
53
+ "description": "Tile shape (a masonry keeps each image's own)."
54
+ },
55
+ "gap": {
56
+ "options": [
57
+ "sm",
58
+ "md",
59
+ "lg"
60
+ ],
61
+ "default": "md",
62
+ "description": "Space between tiles."
63
+ },
64
+ "captionStyle": {
65
+ "options": [
66
+ "below",
67
+ "overlay"
68
+ ],
69
+ "default": "below",
70
+ "description": "below: caption under the image. overlay: over the image foot on hover and keyboard focus, always shown below md."
71
+ },
72
+ "hoverEffect": {
73
+ "options": [
74
+ "none",
75
+ "zoom"
76
+ ],
77
+ "default": "none",
78
+ "description": "zoom: a slight scale on hover, only where reduced motion is not requested."
79
+ }
12
80
  },
13
81
  "props": {
14
- "items": { "type": "array", "label": "Images", "required": true, "items": { "type": "object", "fields": { "image": { "type": "image", "label": "Image", "required": true, "description": "The image." }, "imageAlt": { "type": "string", "label": "Image description", "description": "What the image shows, for readers who cannot see it." }, "caption": { "type": "string", "label": "Caption", "description": "Caption under the enlarged image." } } }, "description": "The images in order (core/gallery inner images, Elementor gallery items, Divi gallery_ids)." },
15
- "heading": { "type": "string", "label": "Heading", "description": "Section heading above the images." },
16
- "closeLabel": { "type": "string", "content": false, "description": "Text of the viewer's close button (ui-strings gallery.close)." },
17
- "tone": { "type": "select", "options": ["default", "muted", "inverse", "accent"], "content": false, "description": "Section background." },
18
- "id": { "type": "string", "content": false, "description": "Section id, unique per page: viewer ids derive from it (`<id>-image-<n>`)." }
82
+ "items": {
83
+ "type": "array",
84
+ "label": "Images",
85
+ "required": true,
86
+ "items": {
87
+ "type": "object",
88
+ "fields": {
89
+ "image": {
90
+ "type": "image",
91
+ "label": "Image",
92
+ "required": true,
93
+ "description": "The image."
94
+ },
95
+ "imageAlt": {
96
+ "type": "string",
97
+ "label": "Image description",
98
+ "description": "What the image shows, for readers who cannot see it."
99
+ },
100
+ "caption": {
101
+ "type": "string",
102
+ "label": "Caption",
103
+ "description": "Caption under the enlarged image."
104
+ },
105
+ "full": {
106
+ "type": "image",
107
+ "label": "Full-size image",
108
+ "description": "The original file the viewer shows large, where the source links to it; otherwise the image itself."
109
+ }
110
+ }
111
+ },
112
+ "description": "The images in order (core/gallery inner images, Elementor gallery items, Divi gallery_ids)."
113
+ },
114
+ "heading": {
115
+ "type": "string",
116
+ "label": "Heading",
117
+ "description": "Section heading above the images."
118
+ },
119
+ "closeLabel": {
120
+ "type": "string",
121
+ "content": false,
122
+ "description": "Text of the viewer's close button (ui-strings gallery.close)."
123
+ },
124
+ "tone": {
125
+ "type": "select",
126
+ "options": [
127
+ "default",
128
+ "muted",
129
+ "inverse",
130
+ "accent"
131
+ ],
132
+ "content": false,
133
+ "description": "Section background."
134
+ },
135
+ "id": {
136
+ "type": "string",
137
+ "content": false,
138
+ "description": "Section id, unique per page: viewer ids derive from it (`<id>-image-<n>`)."
139
+ },
140
+ "enlargeLabel": {
141
+ "type": "string",
142
+ "default": "Enlarge",
143
+ "content": false,
144
+ "description": "Prefix of each thumbnail button's name for screen readers (\"Enlarge: <description>\")."
145
+ },
146
+ "regionLabel": {
147
+ "type": "string",
148
+ "default": "Images",
149
+ "content": false,
150
+ "description": "Name of the scrolling row's region when the gallery has no heading."
151
+ },
152
+ "measured": {
153
+ "type": "object",
154
+ "content": false,
155
+ "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.",
156
+ "fields": {
157
+ "containerPx": {
158
+ "type": "integer",
159
+ "min": 0,
160
+ "max": 2560,
161
+ "description": "The frame's content width."
162
+ },
163
+ "padY": {
164
+ "type": "integer",
165
+ "min": 0,
166
+ "max": 400,
167
+ "description": "Top and bottom padding."
168
+ },
169
+ "padX": {
170
+ "type": "integer",
171
+ "min": 0,
172
+ "max": 200,
173
+ "description": "The frame's side padding (gutter)."
174
+ },
175
+ "bg": {
176
+ "type": "object",
177
+ "description": "The section's background.",
178
+ "fields": {
179
+ "color": {
180
+ "type": "string",
181
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
182
+ },
183
+ "image": {
184
+ "type": "string",
185
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
186
+ },
187
+ "overlay": {
188
+ "type": "string",
189
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
190
+ },
191
+ "size": {
192
+ "type": "string",
193
+ "description": "cover, contain or auto; anything else draws as cover."
194
+ },
195
+ "position": {
196
+ "type": "string",
197
+ "description": "One or two of center/top/bottom/left/right or percentages."
198
+ }
199
+ }
200
+ },
201
+ "columns": {
202
+ "type": "object",
203
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
204
+ "fields": {
205
+ "390": {
206
+ "type": "integer",
207
+ "min": 1,
208
+ "max": 6,
209
+ "description": "Phone."
210
+ },
211
+ "768": {
212
+ "type": "integer",
213
+ "min": 1,
214
+ "max": 6,
215
+ "description": "Tablet."
216
+ },
217
+ "1280": {
218
+ "type": "integer",
219
+ "min": 1,
220
+ "max": 6,
221
+ "description": "Desktop."
222
+ }
223
+ }
224
+ },
225
+ "gap": {
226
+ "type": "integer",
227
+ "min": 0,
228
+ "max": 200,
229
+ "description": "Gap between items."
230
+ },
231
+ "tone": {
232
+ "type": "select",
233
+ "options": [
234
+ "light",
235
+ "dark"
236
+ ],
237
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
238
+ },
239
+ "align": {
240
+ "type": "select",
241
+ "options": [
242
+ "start",
243
+ "center",
244
+ "end"
245
+ ],
246
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
247
+ },
248
+ "radius": {
249
+ "type": "integer",
250
+ "min": 0,
251
+ "max": 200,
252
+ "description": "Card and item corner radius."
253
+ },
254
+ "minHeight": {
255
+ "type": "integer",
256
+ "min": 0,
257
+ "max": 2000,
258
+ "description": "The section's minimum height."
259
+ }
260
+ }
261
+ }
19
262
  },
20
263
  "slots": {},
21
264
  "js": "none",
22
265
  "dependencies": {},
23
266
  "sources": {
24
- "gutenberg": ["core/gallery"],
25
- "elementor": ["elementor/image-gallery", "elementor/gallery"],
26
- "divi": ["divi/et_pb_gallery"],
27
- "classic": ["classic/gallery"]
267
+ "gutenberg": [
268
+ "core/gallery"
269
+ ],
270
+ "elementor": [
271
+ "elementor/image-gallery",
272
+ "elementor/gallery"
273
+ ],
274
+ "divi": [
275
+ "divi/et_pb_gallery"
276
+ ],
277
+ "classic": [
278
+ "classic/gallery"
279
+ ]
28
280
  },
29
- "a11y": "A list of figures. In popover mode each thumbnail is a <button> named by the image's alt (or caption) that opens the viewer; the viewer has a visible close button, and Escape closes it, returning focus to the thumbnail. Captions are <figcaption>s."
281
+ "a11y": "A list of figures. In popover mode each thumbnail is a <button> named by the image's alt (or caption) that opens the viewer; the viewer has a visible close button, and Escape closes it, returning focus to the thumbnail. Captions are <figcaption>s. A scrolling row is a labelled region with tabindex 0 so the keyboard can scroll it; thumbnail buttons are named \"Enlarge: …\". The viewer has no next/previous (it would need script)."
30
282
  }
@@ -5,7 +5,8 @@ import { tv } from 'tailwind-variants'
5
5
  import Button from '../_shared/Button.astro'
6
6
  import KitImage from '../_shared/KitImage.astro'
7
7
  import Section from '../_shared/Section.astro'
8
- import type { ActionInput, ImageInput } from '../_shared/types'
8
+ import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
9
+ import { measuredFrame } from '../_shared/measured'
9
10
  import { coverHeight as heightOf, headingStyle as styleOf } from './hero'
10
11
 
11
12
  const hero = tv({
@@ -78,23 +79,28 @@ interface Props {
78
79
  headingScale?: 'default' | 'theme' | undefined
79
80
  /** The level whose site size the heading takes, when it is not the level it is written at (`h1` size on an `h2`: a page keeps one `h1`). `own` (absent): the heading's own level under `theme`, as before. */
80
81
  headingSize?: 'own' | 'h1' | 'h2' | undefined
82
+ /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
83
+ measured?: MeasuredStyle | undefined
81
84
  }
82
85
 
83
86
 
84
- const { heading, level = 1, eyebrow, lead, actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id, minHeight, headingScale, headingSize } = Astro.props
87
+ const { heading, level = 1, eyebrow, lead, actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id, minHeight, headingScale, headingSize, measured } = Astro.props
85
88
  const s = hero({ layout, align, leadAlign, actionsAlign, width, tone, headingScale, headingSize })
86
89
  const Heading = level === 1 ? 'h1' : 'h2'
87
90
  const headingId = id ? `${id}-title` : undefined
88
91
  const cover = layout === 'cover' && image
89
92
  const coverHeight = cover ? heightOf(minHeight) : undefined
90
93
  const headingStyle = styleOf(headingScale, level, headingSize)
94
+ // A cover draws its own image: the measured background image is that image, and the measured overlay is its scrim.
95
+ const sectionMeasured = cover && measured ? { ...measured, bg: measured.bg ? { ...measured.bg, image: undefined, overlay: undefined } : undefined } : measured
96
+ const scrim = cover ? measuredFrame(measured).overlay : undefined
91
97
  ---
92
98
  {cover && width === 'content'
93
99
  ? (
94
- <Section tone="none" spacing="sm" width="prose" id={id} aria-labelledby={headingId}>
100
+ <Section measured={sectionMeasured} tone="none" spacing="sm" width="prose" id={id} aria-labelledby={headingId}>
95
101
  <div class="relative isolate overflow-hidden bg-ink text-surface">
96
102
  <KitImage image={image} priority sizes="(min-width: 768px) 46rem, 100vw" class="absolute inset-0 -z-10 h-full w-full object-cover" />
97
- <div class="absolute inset-0 -z-10 bg-ink/60" aria-hidden="true" />
103
+ <div class:list={['absolute inset-0 -z-10', !scrim && 'bg-ink/60']} style={scrim ? `background: ${scrim}` : undefined} aria-hidden="true" />
98
104
  <div class="px-6 py-10 md:px-10">
99
105
  <div class={s.grid()} style={coverHeight ? `min-height: ${coverHeight}` : undefined}>
100
106
  <div class={s.copy()}>
@@ -110,10 +116,10 @@ const headingStyle = styleOf(headingScale, level, headingSize)
110
116
  )
111
117
  : cover
112
118
  ? (
113
- <Section tone="inverse" spacing="none" width="none" id={id} aria-labelledby={headingId} class="relative isolate overflow-hidden">
119
+ <Section measured={sectionMeasured} tone="inverse" spacing="none" width="none" id={id} aria-labelledby={headingId} class="relative isolate overflow-hidden">
114
120
  <KitImage image={image} priority sizes="100vw" class="absolute inset-0 -z-10 h-full w-full object-cover" />
115
- <div class="absolute inset-0 -z-10 bg-ink/60" aria-hidden="true" />
116
- <div class="container-page py-16 md:py-28">
121
+ <div class:list={['absolute inset-0 -z-10', !scrim && 'bg-ink/60']} style={scrim ? `background: ${scrim}` : undefined} aria-hidden="true" />
122
+ <div class:list={['container-page', measured?.padY === undefined && 'py-16 md:py-28']}>
117
123
  <div class={s.grid()} style={coverHeight ? `min-height: ${coverHeight}` : undefined}>
118
124
  <div class={s.copy()}>
119
125
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
@@ -126,7 +132,7 @@ const headingStyle = styleOf(headingScale, level, headingSize)
126
132
  </Section>
127
133
  )
128
134
  : (
129
- <Section tone={tone} spacing="lg" id={id} aria-labelledby={headingId}>
135
+ <Section measured={sectionMeasured} tone={tone} spacing="lg" id={id} aria-labelledby={headingId}>
130
136
  {/* Without an image a split has nothing beside the text: the text takes the whole row, not half of it. */}
131
137
  <div class={s.grid({ class: image ? undefined : 'md:grid-cols-1' })}>
132
138
  <div class={s.copy()}>
@@ -239,5 +239,6 @@
239
239
  },
240
240
  "headingSize": "h2"
241
241
  }
242
- }
242
+ },
243
+ {"name": "cover-measured-overlay", "props": {"heading": "Fresh eggs, every morning", "lead": "A family farm since 1962.", "layout": "cover", "image": {"src": "/kit-fixtures/photo-4.svg", "alt": "", "width": 1600, "height": 900}, "actions": [{"label": "Visit us", "href": "/visit/"}], "measured": {"padY": 140, "minHeight": 560, "tone": "dark", "bg": {"image": "/kit-fixtures/photo-4.svg", "overlay": "rgba(20, 40, 10, 0.45)"}}}}
243
244
  ]
@@ -8,6 +8,7 @@
8
8
  "Button.astro",
9
9
  "KitImage.astro",
10
10
  "Section.astro",
11
+ "measured.ts",
11
12
  "types.ts"
12
13
  ],
13
14
  "variants": {
@@ -183,6 +184,116 @@
183
184
  "min": 100,
184
185
  "max": 2000,
185
186
  "description": "Layout cover only: the cover's own height in pixels, as the source sets it (520); absent, the layout's default."
187
+ },
188
+ "measured": {
189
+ "type": "object",
190
+ "content": false,
191
+ "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.",
192
+ "fields": {
193
+ "containerPx": {
194
+ "type": "integer",
195
+ "min": 0,
196
+ "max": 2560,
197
+ "description": "The frame's content width."
198
+ },
199
+ "padY": {
200
+ "type": "integer",
201
+ "min": 0,
202
+ "max": 400,
203
+ "description": "Top and bottom padding."
204
+ },
205
+ "padX": {
206
+ "type": "integer",
207
+ "min": 0,
208
+ "max": 200,
209
+ "description": "The frame's side padding (gutter)."
210
+ },
211
+ "bg": {
212
+ "type": "object",
213
+ "description": "The section's background.",
214
+ "fields": {
215
+ "color": {
216
+ "type": "string",
217
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
218
+ },
219
+ "image": {
220
+ "type": "string",
221
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
222
+ },
223
+ "overlay": {
224
+ "type": "string",
225
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
226
+ },
227
+ "size": {
228
+ "type": "string",
229
+ "description": "cover, contain or auto; anything else draws as cover."
230
+ },
231
+ "position": {
232
+ "type": "string",
233
+ "description": "One or two of center/top/bottom/left/right or percentages."
234
+ }
235
+ }
236
+ },
237
+ "columns": {
238
+ "type": "object",
239
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
240
+ "fields": {
241
+ "390": {
242
+ "type": "integer",
243
+ "min": 1,
244
+ "max": 6,
245
+ "description": "Phone."
246
+ },
247
+ "768": {
248
+ "type": "integer",
249
+ "min": 1,
250
+ "max": 6,
251
+ "description": "Tablet."
252
+ },
253
+ "1280": {
254
+ "type": "integer",
255
+ "min": 1,
256
+ "max": 6,
257
+ "description": "Desktop."
258
+ }
259
+ }
260
+ },
261
+ "gap": {
262
+ "type": "integer",
263
+ "min": 0,
264
+ "max": 200,
265
+ "description": "Gap between items."
266
+ },
267
+ "tone": {
268
+ "type": "select",
269
+ "options": [
270
+ "light",
271
+ "dark"
272
+ ],
273
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
274
+ },
275
+ "align": {
276
+ "type": "select",
277
+ "options": [
278
+ "start",
279
+ "center",
280
+ "end"
281
+ ],
282
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
283
+ },
284
+ "radius": {
285
+ "type": "integer",
286
+ "min": 0,
287
+ "max": 200,
288
+ "description": "Card and item corner radius."
289
+ },
290
+ "minHeight": {
291
+ "type": "integer",
292
+ "min": 0,
293
+ "max": 2000,
294
+ "description": "The section's minimum height."
295
+ }
296
+ }
186
297
  }
187
298
  },
188
299
  "slots": {},
@@ -1,32 +1,58 @@
1
1
  ---
2
- // A grid of client or partner logos, each optionally linked, under an
3
- // optional heading.
2
+ // A grid, a row or an endless strip of client or partner logos, each
3
+ // optionally linked, under an optional heading. The strip is CSS only and
4
+ // stands still for visitors who ask for reduced motion.
4
5
  import { tv } from 'tailwind-variants'
5
6
  import KitImage from '../_shared/KitImage.astro'
6
7
  import Section, { type Tone } from '../_shared/Section.astro'
7
8
  import { imageOf, type ImageSource } from '../_shared/image'
9
+ import { measuredGrid } from '../_shared/measured'
10
+ import type { MeasuredStyle } from '../_shared/types'
8
11
 
9
12
  const cloud = tv({
10
13
  slots: {
11
- heading: 'mb-8 text-center text-sm font-semibold tracking-wide uppercase opacity-80',
14
+ heading: 'mb-8 text-sm font-semibold tracking-wide uppercase opacity-80',
12
15
  list: 'grid grid-cols-2 items-center gap-x-8 gap-y-10 sm:grid-cols-3',
13
16
  item: 'flex items-center justify-center',
14
17
  logo: 'h-10 w-auto max-w-full object-contain transition duration-200',
15
18
  },
16
19
  variants: {
20
+ layout: {
21
+ grid: {},
22
+ // One line of logos that wraps on a narrow screen; the logos keep their own widths.
23
+ row: { list: 'flex flex-wrap items-center gap-x-10 gap-y-6', item: 'shrink-0' },
24
+ // Two copies of the list side by side, moved by one keyframe; see the style block below.
25
+ marquee: { list: 'kit-marquee-track flex w-max items-center gap-x-12', item: 'shrink-0' },
26
+ },
17
27
  columns: {
18
28
  '2': { list: 'sm:grid-cols-2' },
19
29
  '3': { list: 'md:grid-cols-3' },
20
30
  '4': { list: 'md:grid-cols-4' },
31
+ '5': { list: 'md:grid-cols-3 lg:grid-cols-5' },
32
+ '6': { list: 'md:grid-cols-3 lg:grid-cols-6' },
33
+ },
34
+ // A fixed height, the width follows: logos come in every aspect ratio.
35
+ logoHeight: {
36
+ sm: { logo: 'h-8' },
37
+ md: {},
38
+ lg: { logo: 'h-14' },
21
39
  },
22
40
  style: {
23
41
  plain: {},
24
42
  // Colour comes back on hover and on keyboard focus of the logo's link.
25
43
  grayscale: { logo: 'opacity-70 grayscale group-hover:opacity-100 group-hover:grayscale-0 group-focus-visible:opacity-100 group-focus-visible:grayscale-0' },
44
+ // Dark logos on a dark band: drawn white, full strength.
45
+ 'mono-light': { logo: 'brightness-0 invert' },
26
46
  },
47
+ align: { center: { heading: 'text-center' }, start: { heading: 'text-start' } },
27
48
  tone: { default: {}, muted: {}, inverse: {}, accent: { heading: 'opacity-100' } },
28
49
  },
29
- defaultVariants: { columns: '4', style: 'plain', tone: 'default' },
50
+ compoundVariants: [
51
+ { layout: 'row', align: 'center', class: { list: 'justify-center' } },
52
+ { layout: 'row', align: 'start', class: { list: 'justify-start' } },
53
+ { layout: 'grid', align: 'start', class: { item: 'justify-start' } },
54
+ ],
55
+ defaultVariants: { layout: 'grid', columns: '4', logoHeight: 'md', style: 'plain', align: 'center', tone: 'default' },
30
56
  })
31
57
 
32
58
  interface Item {
@@ -40,30 +66,68 @@ interface Item {
40
66
  interface Props {
41
67
  heading?: string | undefined
42
68
  items: Item[]
43
- columns?: '2' | '3' | '4' | undefined
44
- style?: 'plain' | 'grayscale' | undefined
69
+ layout?: 'grid' | 'row' | 'marquee' | undefined
70
+ columns?: '2' | '3' | '4' | '5' | '6' | undefined
71
+ logoHeight?: 'sm' | 'md' | 'lg' | undefined
72
+ style?: 'plain' | 'grayscale' | 'mono-light' | undefined
73
+ align?: 'center' | 'start' | undefined
45
74
  tone?: Tone | undefined
75
+ /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
76
+ measured?: MeasuredStyle | undefined
46
77
  id?: string | undefined
47
78
  }
48
79
 
49
- const { heading, items, columns, style, tone, id } = Astro.props
50
- const s = cloud({ columns, style, tone })
80
+ const { heading, items, layout, columns, logoHeight, style, align, tone, measured, id } = Astro.props
81
+ const s = cloud({ layout, columns, logoHeight, style, align, tone })
82
+ // Measured columns and gap shape the grid layout only; a row or a strip keeps its own flow.
83
+ const grid = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
51
84
  const headingId = heading && id ? `${id}-title` : undefined
85
+ const marquee = layout === 'marquee'
86
+ // A linked logo with no name is read as the site it goes to, never as a made-up word.
87
+ const hostOf = (href: string) => {
88
+ try { return new URL(href, 'https://x.invalid').host.replace(/^www\./, '').replace(/^x\.invalid$/, '') } catch { return '' }
89
+ }
90
+ const shown = items.flatMap((item) => {
91
+ const image = imageOf(item.image, item.imageAlt || (item.href ? hostOf(item.href) : ''))
92
+ return image ? [{ image, href: item.href }] : []
93
+ })
94
+ // About four seconds a logo: the strip reads at a pace, whatever its length.
95
+ const duration = `${Math.max(20, shown.length * 4)}s`
52
96
  ---
53
- <Section tone={tone} id={id} aria-labelledby={headingId}>
97
+ <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
54
98
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
55
- <ul class={s.list()}>
56
- {items.map(item => {
57
- const image = imageOf(item.image, item.imageAlt)
58
- if (!image) return null
59
- const logo = <KitImage image={image} sizes="160px" class={s.logo()} />
60
- return (
99
+ <div class:list={[marquee && 'kit-marquee overflow-hidden']} style={marquee ? `--kit-marquee-duration:${duration}` : undefined}>
100
+ <ul class={s.list({ class: grid })}>
101
+ {shown.map(({ image, href }) => (
61
102
  <li class={s.item()}>
62
- {item.href
63
- ? <a href={item.href} class="group rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4">{logo}</a>
64
- : <span class="group">{logo}</span>}
103
+ {href
104
+ ? <a href={href} class="group rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4"><KitImage image={image} sizes="160px" class={s.logo()} /></a>
105
+ : <span class="group"><KitImage image={image} sizes="160px" class={s.logo()} /></span>}
65
106
  </li>
66
- )
67
- })}
68
- </ul>
107
+ ))}
108
+ </ul>
109
+ {marquee && (
110
+ // The second copy exists only to close the loop: hidden from assistive technology and from the tab order.
111
+ <ul class={`${s.list()} kit-marquee-copy`} aria-hidden="true" inert>
112
+ {shown.map(({ image }) => (
113
+ <li class={s.item()}><span class="group"><KitImage image={image} sizes="160px" class={s.logo()} /></span></li>
114
+ ))}
115
+ </ul>
116
+ )}
117
+ </div>
69
118
  </Section>
119
+ <style>
120
+ /* Reduced motion (the default here): one wrapped row, no copy. */
121
+ .kit-marquee .kit-marquee-track { width: auto; flex-wrap: wrap; justify-content: center; }
122
+ .kit-marquee .kit-marquee-copy { display: none; }
123
+ @media (prefers-reduced-motion: no-preference) {
124
+ .kit-marquee { display: flex; width: 100%; mask-image: linear-gradient(to right, transparent, #000 10%, #000 90%, transparent); }
125
+ .kit-marquee .kit-marquee-track,
126
+ .kit-marquee .kit-marquee-copy { display: flex; flex-wrap: nowrap; width: max-content; flex-shrink: 0; justify-content: flex-start; padding-inline-end: 3rem; animation: kit-marquee var(--kit-marquee-duration, 40s) linear infinite; }
127
+ .kit-marquee:hover .kit-marquee-track,
128
+ .kit-marquee:hover .kit-marquee-copy,
129
+ .kit-marquee:focus-within .kit-marquee-track,
130
+ .kit-marquee:focus-within .kit-marquee-copy { animation-play-state: paused; }
131
+ }
132
+ @keyframes kit-marquee { to { transform: translateX(-100%); } }
133
+ </style>