@contentrain/astro-kit 0.31.0 → 0.33.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 (102) hide show
  1. package/catalog.json +4600 -1584
  2. package/components/_shared/Button.astro +2 -1
  3. package/components/_shared/KitImage.astro +9 -4
  4. package/components/_shared/Section.astro +3 -1
  5. package/components/_shared/SectionHeader.astro +27 -0
  6. package/components/_shared/measured.ts +23 -0
  7. package/components/_shared/safe-url.ts +67 -0
  8. package/components/_shared/social.ts +73 -0
  9. package/components/announcement/Announcement.astro +2 -1
  10. package/components/announcement/meta.json +1 -1
  11. package/components/band/Band.astro +3 -2
  12. package/components/band/meta.json +1 -0
  13. package/components/breadcrumb/Breadcrumb.astro +2 -1
  14. package/components/breadcrumb/meta.json +1 -1
  15. package/components/card-grid/CardGrid.astro +2 -1
  16. package/components/card-grid/meta.json +1 -0
  17. package/components/consent/Consent.astro +2 -1
  18. package/components/consent/meta.json +1 -1
  19. package/components/contact-details/ContactDetails.astro +3 -2
  20. package/components/contact-details/meta.json +1 -0
  21. package/components/contact-form/ContactForm.astro +51 -14
  22. package/components/contact-form/fixtures.json +169 -9
  23. package/components/contact-form/meta.json +284 -15
  24. package/components/cta/Cta.astro +52 -12
  25. package/components/cta/fixtures.json +5 -1
  26. package/components/cta/meta.json +11 -2
  27. package/components/dialog/Dialog.astro +113 -0
  28. package/components/dialog/fixtures.json +84 -0
  29. package/components/dialog/meta.json +252 -0
  30. package/components/embed/meta.json +1 -1
  31. package/components/faq/Faq.astro +60 -17
  32. package/components/faq/fixtures.json +79 -0
  33. package/components/faq/meta.json +9 -3
  34. package/components/feature-list/FeatureList.astro +28 -4
  35. package/components/feature-list/meta.json +16 -3
  36. package/components/footer/Footer.astro +5 -4
  37. package/components/footer/meta.json +1 -0
  38. package/components/gallery/Gallery.astro +49 -14
  39. package/components/gallery/fixtures.json +275 -0
  40. package/components/gallery/meta.json +267 -14
  41. package/components/header/Header.astro +17 -5
  42. package/components/header/fixtures.json +90 -1
  43. package/components/header/meta.json +5 -3
  44. package/components/hero/meta.json +1 -0
  45. package/components/language-switcher/LanguageSwitcher.astro +68 -0
  46. package/components/language-switcher/fixtures.json +51 -0
  47. package/components/language-switcher/meta.json +73 -0
  48. package/components/logo-cloud/LogoCloud.astro +86 -21
  49. package/components/logo-cloud/fixtures.json +239 -0
  50. package/components/logo-cloud/meta.json +145 -5
  51. package/components/nav/Nav.astro +77 -24
  52. package/components/nav/NavTree.astro +40 -0
  53. package/components/nav/fixtures.json +165 -0
  54. package/components/nav/meta.json +11 -2
  55. package/components/newsletter/Newsletter.astro +2 -1
  56. package/components/newsletter/meta.json +1 -0
  57. package/components/pagination/Pagination.astro +4 -3
  58. package/components/pagination/meta.json +1 -1
  59. package/components/post-card/PostCard.astro +8 -5
  60. package/components/post-card/meta.json +3 -2
  61. package/components/post-list/PostList.astro +115 -0
  62. package/components/post-list/fixtures.json +387 -0
  63. package/components/post-list/meta.json +309 -0
  64. package/components/pricing/Pricing.astro +128 -24
  65. package/components/pricing/fixtures.json +288 -4
  66. package/components/pricing/meta.json +188 -1
  67. package/components/search-box/SearchBox.astro +51 -0
  68. package/components/search-box/fixtures.json +31 -0
  69. package/components/search-box/meta.json +75 -0
  70. package/components/slider/Slider.astro +19 -115
  71. package/components/slider/SliderTrack.astro +178 -0
  72. package/components/slider/fixtures.json +2 -1
  73. package/components/slider/meta.json +11 -5
  74. package/components/social-links/SocialLinks.astro +74 -0
  75. package/components/social-links/fixtures.json +67 -0
  76. package/components/social-links/meta.json +94 -0
  77. package/components/split/Split.astro +76 -14
  78. package/components/split/fixtures.json +8 -1
  79. package/components/split/meta.json +226 -4
  80. package/components/stats/Stats.astro +54 -11
  81. package/components/stats/fixtures.json +260 -0
  82. package/components/stats/meta.json +151 -4
  83. package/components/steps/Steps.astro +36 -8
  84. package/components/steps/fixtures.json +217 -5
  85. package/components/steps/meta.json +157 -3
  86. package/components/tabs/Tabs.astro +10 -3
  87. package/components/tabs/fixtures.json +4 -0
  88. package/components/tabs/meta.json +5 -2
  89. package/components/team/Team.astro +54 -9
  90. package/components/team/fixtures.json +217 -4
  91. package/components/team/meta.json +169 -3
  92. package/components/team/team.ts +4 -0
  93. package/components/testimonial/Testimonial.astro +71 -21
  94. package/components/testimonial/fixtures.json +73 -0
  95. package/components/testimonial/meta.json +168 -6
  96. package/dist/index.mjs +1 -1
  97. package/dist/index.mjs.map +1 -1
  98. package/mapping/classic.json +124 -3
  99. package/mapping/divi.json +26 -8
  100. package/mapping/elementor.json +133 -14
  101. package/mapping/gutenberg.json +38 -10
  102. package/package.json +2 -2
@@ -2,5 +2,12 @@
2
2
  { "name": "end-default", "props": { "eyebrow": "Our studio", "heading": "Small team, careful work", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "actions": [{ "label": "Meet the team", "href": "/team/" }, { "label": "Our process", "href": "/process/", "style": "ghost" }], "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 } } },
3
3
  { "name": "start-muted", "props": { "imageSide": "start", "tone": "muted", "heading": "Content you can edit", "body": "Pages live as structured content in your own repository, editable in [Contentrain Studio](/studio/).", "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "", "width": 1200, "height": 800 } } },
4
4
  { "name": "center-inverse", "props": { "align": "center", "tone": "inverse", "heading": "Fast by default", "body": "Static HTML and one stylesheet.", "actions": [{ "label": "See the numbers", "href": "/performance/" }], "image": { "src": "/kit-fixtures/photo-4.svg", "alt": "", "width": 1200, "height": 800 } } },
5
- { "name": "accent", "props": { "tone": "accent", "eyebrow": "New", "heading": "Now with search", "body": "Every page is indexed at build time.", "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "", "width": 1200, "height": 800 } } }
5
+ { "name": "accent", "props": { "tone": "accent", "eyebrow": "New", "heading": "Now with search", "body": "Every page is indexed at build time.", "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "", "width": 1200, "height": 800 } } },
6
+ { "name": "ratio-5-7-cover-square", "props": { "heading": "Small team, careful work", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "ratio": "5:7", "imageFit": "cover", "imageShape": "none", "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 } } },
7
+ { "name": "ratio-7-5-start-features", "props": { "heading": "Why teams choose us", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "ratio": "7:5", "imageSide": "start", "features": [{ "title": "Fast", "text": "Static pages, one stylesheet.", "icon": "bolt" }, { "title": "Safe", "text": "Your repository stays yours.", "icon": "shield" }, { "title": "Editable", "text": "Change copy without a developer.", "icon": "chat" }], "featureStyle": "icon", "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "tone": "muted" } },
8
+ { "name": "circle-media-first", "props": { "heading": "Meet the founder", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "imageShape": "circle", "stackOrder": "media-first", "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 } } },
9
+ { "name": "bleed-edge-end", "props": { "heading": "A wider picture", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "imageBleed": "edge", "image": { "src": "/kit-fixtures/photo-4.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "tone": "brand" } },
10
+ { "name": "bleed-edge-start-none", "props": { "heading": "From the other side", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "imageBleed": "edge", "imageSide": "start", "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "tone": "none" } },
11
+ { "name": "video-sticky-check", "props": { "heading": "See how it works", "headingLevel": 3, "mediaKind": "video", "videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "playLabel": "Play video", "sticky": true, "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.\n\nWe have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.\n\nWe have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "features": [{ "title": "Fast", "text": "Static pages, one stylesheet.", "icon": "bolt" }, { "title": "Safe", "text": "Your repository stays yours.", "icon": "shield" }], "image": { "src": "/kit-fixtures/photo-6.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 } } },
12
+ { "name": "measured-frame", "props": { "eyebrow": "Our studio", "heading": "Small team, careful work", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "actions": [{ "label": "Meet the team", "href": "/team/" }, { "label": "Our process", "href": "/process/", "style": "ghost" }], "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "measured": { "containerPx": 1000, "padY": 80, "bg": { "color": "#eef2ff" }, "tone": "light" } } }
6
13
  ]
@@ -3,12 +3,16 @@
3
3
  "name": "Split",
4
4
  "description": "An image beside a block of text — eyebrow, heading, a markdown body and buttons — on either side, stacked on phones. The media-and-text section of a landing page (Gutenberg media-text, a two-column Elementor container with an image, Divi's image + text row).",
5
5
  "category": "section",
6
- "uses": [],
6
+ "uses": [
7
+ "embed",
8
+ "feature-list"
9
+ ],
7
10
  "shared": [
8
11
  "Button.astro",
9
12
  "KitImage.astro",
10
13
  "Section.astro",
11
14
  "measured.ts",
15
+ "safe-url.ts",
12
16
  "types.ts"
13
17
  ],
14
18
  "variants": {
@@ -20,6 +24,64 @@
20
24
  "default": "end",
21
25
  "description": "Which side the image takes on wide screens: start (left in left-to-right text) or end. Phones always show it after the text."
22
26
  },
27
+ "ratio": {
28
+ "options": [
29
+ "1:1",
30
+ "5:7",
31
+ "7:5"
32
+ ],
33
+ "default": "1:1",
34
+ "description": "The text column's share against the image's: equal, text 5 to image 7, or text 7 to image 5."
35
+ },
36
+ "imageFit": {
37
+ "options": [
38
+ "contain",
39
+ "cover"
40
+ ],
41
+ "default": "contain",
42
+ "description": "contain: the image at its own proportions. cover: it fills the column's height."
43
+ },
44
+ "imageShape": {
45
+ "options": [
46
+ "none",
47
+ "rounded",
48
+ "circle"
49
+ ],
50
+ "default": "rounded",
51
+ "description": "The image's corners: square, the theme's rounding, or a circle."
52
+ },
53
+ "imageBleed": {
54
+ "options": [
55
+ "none",
56
+ "edge"
57
+ ],
58
+ "default": "none",
59
+ "description": "edge: the image runs on to the edge of the screen on its side (wide screens)."
60
+ },
61
+ "mediaKind": {
62
+ "options": [
63
+ "image",
64
+ "video"
65
+ ],
66
+ "default": "image",
67
+ "description": "image: the picture. video: the picture is the poster of a video that loads only when played."
68
+ },
69
+ "featureStyle": {
70
+ "options": [
71
+ "check",
72
+ "icon"
73
+ ],
74
+ "default": "check",
75
+ "description": "The marks of the points under the text: a check, or each point's own icon."
76
+ },
77
+ "stackOrder": {
78
+ "options": [
79
+ "text-first",
80
+ "media-first"
81
+ ],
82
+ "default": "text-first",
83
+ "description": "On a phone: the text first, or the picture first."
84
+ },
23
85
  "align": {
24
86
  "options": [
25
87
  "start",
@@ -33,10 +95,12 @@
33
95
  "default",
34
96
  "muted",
35
97
  "inverse",
36
- "accent"
98
+ "accent",
99
+ "brand",
100
+ "none"
37
101
  ],
38
102
  "default": "default",
39
- "description": "Section background."
103
+ "description": "Section background (brand: the site's brand colour; none: the page's own)."
40
104
  }
41
105
  },
42
106
  "props": {
@@ -46,6 +110,12 @@
46
110
  "required": true,
47
111
  "description": "The section heading."
48
112
  },
113
+ "headingLevel": {
114
+ "type": "integer",
115
+ "default": 2,
116
+ "content": false,
117
+ "description": "The heading's level: 2 under the page title; 3 or 4 when the section sits inside another."
118
+ },
49
119
  "eyebrow": {
50
120
  "type": "string",
51
121
  "label": "Eyebrow",
@@ -121,10 +191,162 @@
121
191
  },
122
192
  "description": "The image beside the text. Width and height, when known, let the site reserve its space."
123
193
  },
194
+ "features": {
195
+ "type": "array",
196
+ "label": "Points",
197
+ "items": {
198
+ "type": "object",
199
+ "fields": {
200
+ "title": {
201
+ "type": "string",
202
+ "label": "Title",
203
+ "required": true,
204
+ "description": "The point, in a few words."
205
+ },
206
+ "text": {
207
+ "type": "string",
208
+ "label": "Text",
209
+ "description": "A line about it."
210
+ },
211
+ "icon": {
212
+ "type": "string",
213
+ "label": "Icon",
214
+ "description": "A kit icon name (check, star, bolt, shield, heart, globe, clock, chat)."
215
+ }
216
+ }
217
+ },
218
+ "description": "Short points under the text (an Elementor icon list or icon box, a Gutenberg list), each with a title and a line."
219
+ },
220
+ "videoUrl": {
221
+ "type": "url",
222
+ "label": "Video",
223
+ "description": "A video page (YouTube, Vimeo); the image is its poster and nothing loads from the provider until it is played."
224
+ },
225
+ "playLabel": {
226
+ "type": "string",
227
+ "content": false,
228
+ "description": "The play button's text (ui-strings embed.play)."
229
+ },
230
+ "measured": {
231
+ "type": "object",
232
+ "content": false,
233
+ "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.",
234
+ "fields": {
235
+ "containerPx": {
236
+ "type": "integer",
237
+ "min": 0,
238
+ "max": 2560,
239
+ "description": "The frame's content width."
240
+ },
241
+ "padY": {
242
+ "type": "integer",
243
+ "min": 0,
244
+ "max": 400,
245
+ "description": "Top and bottom padding."
246
+ },
247
+ "padX": {
248
+ "type": "integer",
249
+ "min": 0,
250
+ "max": 200,
251
+ "description": "The frame's side padding (gutter)."
252
+ },
253
+ "bg": {
254
+ "type": "object",
255
+ "description": "The section's background.",
256
+ "fields": {
257
+ "color": {
258
+ "type": "string",
259
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
260
+ },
261
+ "image": {
262
+ "type": "string",
263
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
264
+ },
265
+ "overlay": {
266
+ "type": "string",
267
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
268
+ },
269
+ "size": {
270
+ "type": "string",
271
+ "description": "cover, contain or auto; anything else draws as cover."
272
+ },
273
+ "position": {
274
+ "type": "string",
275
+ "description": "One or two of center/top/bottom/left/right or percentages."
276
+ }
277
+ }
278
+ },
279
+ "columns": {
280
+ "type": "object",
281
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
282
+ "fields": {
283
+ "390": {
284
+ "type": "integer",
285
+ "min": 1,
286
+ "max": 6,
287
+ "description": "Phone."
288
+ },
289
+ "768": {
290
+ "type": "integer",
291
+ "min": 1,
292
+ "max": 6,
293
+ "description": "Tablet."
294
+ },
295
+ "1280": {
296
+ "type": "integer",
297
+ "min": 1,
298
+ "max": 6,
299
+ "description": "Desktop."
300
+ }
301
+ }
302
+ },
303
+ "gap": {
304
+ "type": "integer",
305
+ "min": 0,
306
+ "max": 200,
307
+ "description": "Gap between items."
308
+ },
309
+ "tone": {
310
+ "type": "select",
311
+ "options": [
312
+ "light",
313
+ "dark"
314
+ ],
315
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
316
+ },
317
+ "align": {
318
+ "type": "select",
319
+ "options": [
320
+ "start",
321
+ "center",
322
+ "end"
323
+ ],
324
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
325
+ },
326
+ "radius": {
327
+ "type": "integer",
328
+ "min": 0,
329
+ "max": 200,
330
+ "description": "Card and item corner radius."
331
+ },
332
+ "minHeight": {
333
+ "type": "integer",
334
+ "min": 0,
335
+ "max": 2000,
336
+ "description": "The section's minimum height."
337
+ }
338
+ }
339
+ },
124
340
  "id": {
125
341
  "type": "string",
126
342
  "content": false,
127
343
  "description": "Anchor id of the section; the heading gets `<id>-title`."
344
+ },
345
+ "sticky": {
346
+ "type": "boolean",
347
+ "default": false,
348
+ "content": false,
349
+ "description": "The picture stays in view while a long text scrolls past it (wide screens)."
128
350
  }
129
351
  },
130
352
  "slots": {},
@@ -141,7 +363,7 @@
141
363
  "divi/et_pb_image"
142
364
  ]
143
365
  },
144
- "a11y": "The section is labelled by its heading. The image is a separate figure in reading order after the text; an empty description marks a decorative one.",
366
+ "a11y": "The section is labelled by its heading. The image is a separate figure in reading order after the text; an empty description marks a decorative one. With stackOrder media-first the picture comes first on a phone by CSS order only: the DOM and so the reading order stay text first, which is right when the picture is decoration; give the picture an empty description then.",
145
367
  "pattern": {
146
368
  "summary": "image beside heading + text",
147
369
  "signals": [
@@ -2,24 +2,56 @@
2
2
  // A row of figures, each with a short label, under an optional heading and
3
3
  // intro. Figures are static text: no counting animation.
4
4
  import { tv } from 'tailwind-variants'
5
+ import KitImage from '../_shared/KitImage.astro'
5
6
  import Section, { type Tone } from '../_shared/Section.astro'
7
+ import { imageOf, type ImageSource } from '../_shared/image'
8
+ import { measuredGrid } from '../_shared/measured'
9
+ import type { MeasuredStyle } from '../_shared/types'
6
10
 
7
11
  const stats = tv({
8
12
  slots: {
9
13
  list: 'grid grid-cols-2 gap-6 md:gap-8',
10
14
  item: 'flex gap-1',
11
- value: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight tabular-nums md:text-5xl',
15
+ // nowrap keeps a figure with its prefix and suffix ("99.9%" never splits into "99.9" / "%").
16
+ // The size is one pair of variables (valueSize sets them): --stat-max below md, --stat-max-md from md. Its line
17
+ // height comes with valueSize too: a font-size class merged in later would drop one set here.
18
+ value: 'text-[length:var(--stat-max,2.25rem)] [font-weight:var(--font-weight-heading,700)] tracking-tight tabular-nums whitespace-nowrap [--stat-max-md:3rem] md:text-[length:var(--stat-max-md)]',
19
+ icon: 'mb-3 block size-10 object-contain',
12
20
  label: 'text-sm opacity-80',
13
21
  },
14
22
  variants: {
23
+ // grid: figures in columns. inline: one line that wraps, for two to four short figures.
24
+ layout: {
25
+ // Each cell is a size container; a figure is no wider than the cell: its size is capped by 100cqi over its
26
+ // character count (--stat-chars, about 0.66em a character), so a long one ("1.250+", "€12.5M") in four columns at
27
+ // tablet width shrinks to fit the card, and a short one keeps the size valueSize gives it.
28
+ grid: {
29
+ item: '[container-type:inline-size] min-w-0',
30
+ value: 'text-[length:min(var(--stat-max,2.25rem),max(1.25rem,calc(100cqi_/_(var(--stat-chars,1)_*_0.66))))] md:text-[length:min(var(--stat-max-md),max(1.25rem,calc(100cqi_/_(var(--stat-chars,1)_*_0.66))))]',
31
+ },
32
+ inline: { list: 'flex flex-wrap gap-x-12 gap-y-6 md:gap-x-16' },
33
+ },
15
34
  columns: {
16
35
  '2': { list: 'md:grid-cols-2' },
17
36
  '3': { list: 'md:grid-cols-3' },
18
37
  '4': { list: 'md:grid-cols-4' },
38
+ '5': { list: 'md:grid-cols-3 lg:grid-cols-5' },
39
+ '6': { list: 'md:grid-cols-3 lg:grid-cols-6' },
19
40
  },
20
41
  style: {
21
42
  plain: {},
22
43
  card: { item: 'rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60' },
44
+ // Hairlines between the figures, none before the first, no box: across on a wide screen, above each on a phone.
45
+ // Meant for a single row (figures no more than the columns); a wrapped row starts with a line.
46
+ divided: { item: 'border-t border-line pt-6 first:border-t-0 first:pt-0 md:border-t-0 md:pt-0 md:border-s md:ps-6 md:first:border-s-0 md:first:ps-0' },
47
+ },
48
+ align: { start: {}, center: { item: 'items-center text-center', icon: 'mx-auto' } },
49
+ // The figure's size: md, lg (the default), xl, or a display number.
50
+ valueSize: {
51
+ md: { value: '[--stat-max:1.875rem] [--stat-max-md:2.25rem] leading-[1.2] md:leading-[1.1112]' },
52
+ lg: { value: 'leading-[1.1112] md:leading-none' },
53
+ xl: { value: '[--stat-max:3rem] [--stat-max-md:3.75rem] leading-none' },
54
+ display: { value: '[--stat-max:3.75rem] [--stat-max-md:4.5rem] leading-none' },
23
55
  },
24
56
  // accent/accent-ink is only ~5:1 — dimming text on it drops it under 4.5:1 (WCAG 1.4.3).
25
57
  tone: { default: {}, muted: {}, inverse: {}, accent: { label: 'opacity-100' } },
@@ -29,7 +61,7 @@ const stats = tv({
29
61
  // Elementor counter names itself first). The figure is drawn on top either way.
30
62
  order: { 'label-first': { item: 'flex-col-reverse' }, 'value-first': { item: 'flex-col' } },
31
63
  },
32
- defaultVariants: { columns: '3', style: 'plain', tone: 'default', labelSize: 'sm', order: 'label-first' },
64
+ defaultVariants: { layout: 'grid', columns: '3', style: 'plain', align: 'start', valueSize: 'lg', tone: 'default', labelSize: 'sm', order: 'label-first' },
33
65
  })
34
66
 
35
67
  interface Item {
@@ -37,26 +69,37 @@ interface Item {
37
69
  label: string
38
70
  prefix?: string | undefined
39
71
  suffix?: string | undefined
72
+ /** A small picture above the figure (its address); decorative, so it carries no text. */
73
+ icon?: ImageSource | undefined
40
74
  }
41
75
 
42
76
  interface Props {
43
77
  heading?: string | undefined
44
78
  intro?: string | undefined
45
79
  items: Item[]
46
- columns?: '2' | '3' | '4' | undefined
47
- style?: 'plain' | 'card' | undefined
80
+ layout?: 'grid' | 'inline' | undefined
81
+ columns?: '2' | '3' | '4' | '5' | '6' | undefined
82
+ style?: 'plain' | 'card' | 'divided' | undefined
83
+ align?: 'start' | 'center' | undefined
84
+ valueSize?: 'md' | 'lg' | 'xl' | 'display' | undefined
48
85
  labelSize?: 'sm' | 'base' | 'lg' | undefined
49
86
  order?: 'label-first' | 'value-first' | undefined
50
87
  tone?: Tone | undefined
88
+ /** 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. */
89
+ measured?: MeasuredStyle | undefined
51
90
  id?: string | undefined
52
91
  }
53
92
 
54
- const { heading, intro, items, columns, style, labelSize, order, tone, id } = Astro.props
55
- const s = stats({ columns, style, labelSize, order, tone })
93
+ const { heading, intro, items, layout, columns, style, align, valueSize, labelSize, order, tone, measured, id } = Astro.props
94
+ const s = stats({ layout, columns, style, align, valueSize, labelSize, order, tone })
95
+ const grid = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
56
96
  const headingId = heading && id ? `${id}-title` : undefined
97
+ const iconOf = (item: Item) => imageOf(item.icon, '')
98
+ // The figure's width in characters, for the size it may take in a narrow cell.
99
+ const widthOf = (item: Item) => `--stat-chars:${(item.prefix ?? '').length + item.value.length + (item.suffix ?? '').length}`
57
100
  // A <dl> names its term first, so figure-first is a plain list, read in the order it is drawn (WCAG 1.3.2).
58
101
  ---
59
- <Section tone={tone} id={id} aria-labelledby={headingId}>
102
+ <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
60
103
  {(heading || intro) && (
61
104
  <header class="mb-10 flex max-w-2xl flex-col gap-3">
62
105
  {heading && <h2 id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{heading}</h2>}
@@ -64,20 +107,20 @@ const headingId = heading && id ? `${id}-title` : undefined
64
107
  </header>
65
108
  )}
66
109
  {order === 'value-first' ? (
67
- <ul class={s.list()} role="list">
110
+ <ul class={s.list({ class: grid })} role="list">
68
111
  {items.map(item => (
69
112
  <li class={s.item()}>
70
- <p class={s.value()}>{item.prefix}{item.value}{item.suffix}</p>
113
+ <p class={s.value()} style={widthOf(item)}>{iconOf(item) && <KitImage image={iconOf(item)!} sizes="40px" class={s.icon()} />}{item.prefix}{item.value}{item.suffix}</p>
71
114
  <p class={s.label()}>{item.label}</p>
72
115
  </li>
73
116
  ))}
74
117
  </ul>
75
118
  ) : (
76
- <dl class={s.list()}>
119
+ <dl class={s.list({ class: grid })}>
77
120
  {items.map(item => (
78
121
  <div class={s.item()}>
79
122
  <dt class={s.label()}>{item.label}</dt>
80
- <dd class={s.value()}>{item.prefix}{item.value}{item.suffix}</dd>
123
+ <dd class={s.value()} style={widthOf(item)}>{iconOf(item) && <KitImage image={iconOf(item)!} sizes="40px" class={s.icon()} />}{item.prefix}{item.value}{item.suffix}</dd>
81
124
  </div>
82
125
  ))}
83
126
  </dl>