@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
@@ -8,6 +8,7 @@
8
8
  "KitImage.astro",
9
9
  "Section.astro",
10
10
  "image.ts",
11
+ "measured.ts",
11
12
  "types.ts"
12
13
  ],
13
14
  "variants": {
@@ -15,10 +16,11 @@
15
16
  "options": [
16
17
  "2",
17
18
  "3",
18
- "4"
19
+ "4",
20
+ "5"
19
21
  ],
20
22
  "default": "3",
21
- "description": "Columns on wide screens; one on phones, two on tablets."
23
+ "description": "Members per row on wide screens; one on phones, two on tablets."
22
24
  },
23
25
  "style": {
24
26
  "options": [
@@ -35,6 +37,32 @@
35
37
  ],
36
38
  "default": "accent",
37
39
  "description": "The member's role. accent: a small accent-coloured line. plain: body-size text in the text colour, as a byline."
40
+ },
41
+ "layout": {
42
+ "options": [
43
+ "grid",
44
+ "list",
45
+ "featured"
46
+ ],
47
+ "default": "grid",
48
+ "description": "grid: cards in columns. list: one row each, photo beside text. featured: the first member large over a grid."
49
+ },
50
+ "imageShape": {
51
+ "options": [
52
+ "square",
53
+ "rounded",
54
+ "circle"
55
+ ],
56
+ "default": "square",
57
+ "description": "Shape of the portrait."
58
+ },
59
+ "align": {
60
+ "options": [
61
+ "start",
62
+ "center"
63
+ ],
64
+ "default": "start",
65
+ "description": "Text and socials from the start edge, or centred."
38
66
  }
39
67
  },
40
68
  "props": {
@@ -85,6 +113,14 @@
85
113
  "type": "url",
86
114
  "label": "Link",
87
115
  "description": "Their profile page; makes the whole entry a link."
116
+ },
117
+ "links": {
118
+ "type": "array",
119
+ "label": "Profile links",
120
+ "description": "Addresses of the member's social profiles, in order; each is labelled by the network its host names (LinkedIn, GitHub, …).",
121
+ "items": {
122
+ "type": "url"
123
+ }
88
124
  }
89
125
  }
90
126
  },
@@ -94,6 +130,135 @@
94
130
  "type": "string",
95
131
  "content": false,
96
132
  "description": "Anchor id of the section; the heading gets `<id>-title`."
133
+ },
134
+ "bioClamp": {
135
+ "type": "integer",
136
+ "min": 2,
137
+ "max": 4,
138
+ "content": false,
139
+ "description": "Clamp a long bio to this many lines; the full text opens in a native <details> (\"Read more\"), no script."
140
+ },
141
+ "readMoreLabel": {
142
+ "type": "string",
143
+ "default": "Read more",
144
+ "content": false,
145
+ "description": "Text of the bio disclosure."
146
+ },
147
+ "socialLabel": {
148
+ "type": "string",
149
+ "default": "{name} on {network}",
150
+ "content": false,
151
+ "description": "Accessible name of a profile link; {name} and {network} are replaced."
152
+ },
153
+ "measured": {
154
+ "type": "object",
155
+ "content": false,
156
+ "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.",
157
+ "fields": {
158
+ "containerPx": {
159
+ "type": "integer",
160
+ "min": 0,
161
+ "max": 2560,
162
+ "description": "The frame's content width."
163
+ },
164
+ "padY": {
165
+ "type": "integer",
166
+ "min": 0,
167
+ "max": 400,
168
+ "description": "Top and bottom padding."
169
+ },
170
+ "padX": {
171
+ "type": "integer",
172
+ "min": 0,
173
+ "max": 200,
174
+ "description": "The frame's side padding (gutter)."
175
+ },
176
+ "bg": {
177
+ "type": "object",
178
+ "description": "The section's background.",
179
+ "fields": {
180
+ "color": {
181
+ "type": "string",
182
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
183
+ },
184
+ "image": {
185
+ "type": "string",
186
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
187
+ },
188
+ "overlay": {
189
+ "type": "string",
190
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
191
+ },
192
+ "size": {
193
+ "type": "string",
194
+ "description": "cover, contain or auto; anything else draws as cover."
195
+ },
196
+ "position": {
197
+ "type": "string",
198
+ "description": "One or two of center/top/bottom/left/right or percentages."
199
+ }
200
+ }
201
+ },
202
+ "columns": {
203
+ "type": "object",
204
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
205
+ "fields": {
206
+ "390": {
207
+ "type": "integer",
208
+ "min": 1,
209
+ "max": 6,
210
+ "description": "Phone."
211
+ },
212
+ "768": {
213
+ "type": "integer",
214
+ "min": 1,
215
+ "max": 6,
216
+ "description": "Tablet."
217
+ },
218
+ "1280": {
219
+ "type": "integer",
220
+ "min": 1,
221
+ "max": 6,
222
+ "description": "Desktop."
223
+ }
224
+ }
225
+ },
226
+ "gap": {
227
+ "type": "integer",
228
+ "min": 0,
229
+ "max": 200,
230
+ "description": "Gap between items."
231
+ },
232
+ "tone": {
233
+ "type": "select",
234
+ "options": [
235
+ "light",
236
+ "dark"
237
+ ],
238
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
239
+ },
240
+ "align": {
241
+ "type": "select",
242
+ "options": [
243
+ "start",
244
+ "center",
245
+ "end"
246
+ ],
247
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
248
+ },
249
+ "radius": {
250
+ "type": "integer",
251
+ "min": 0,
252
+ "max": 200,
253
+ "description": "Card and item corner radius."
254
+ },
255
+ "minHeight": {
256
+ "type": "integer",
257
+ "min": 0,
258
+ "max": 2000,
259
+ "description": "The section's minimum height."
260
+ }
261
+ }
97
262
  }
98
263
  },
99
264
  "slots": {},
@@ -110,7 +275,7 @@
110
275
  "divi/et_pb_team_member"
111
276
  ]
112
277
  },
113
- "a11y": "Members are a <ul>; each name is an <h3> under the section's <h2> (an <h2> when the section has no heading), and a linked member's link text is the name. A portrait's text defaults to the name. The section is labelled by its heading when it has one.",
278
+ "a11y": "Members are a <ul>; each name is an <h3> under the section's <h2> (an <h2> when the section has no heading), and a linked member's link text is the name. A portrait's text defaults to the name. The section is labelled by its heading when it has one. A member with socials keeps the name link and the social links apart (no nested links); a clamped bio opens in a <details> with the whole text.",
114
279
  "pattern": {
115
280
  "summary": "repeated person units: portrait + name + role",
116
281
  "signals": [
@@ -0,0 +1,9 @@
1
+ // The network a profile address belongs to, read from its host (`linkedin.com` → LinkedIn); an unknown host is shown as itself.
2
+ const NETWORKS: Record<string, string> = { 'linkedin.com': 'LinkedIn', 'github.com': 'GitHub', 'x.com': 'X', 'twitter.com': 'X', 'instagram.com': 'Instagram', 'facebook.com': 'Facebook', 'youtube.com': 'YouTube', 'dribbble.com': 'Dribbble', 'behance.net': 'Behance', 'medium.com': 'Medium' }
3
+
4
+ export const profile = (href: string) => {
5
+ let host = href
6
+ try { host = new URL(href).hostname.replace(/^www\./, '') } catch { /* a relative address keeps its own text */ }
7
+ // `mailto:` and `tel:` addresses have no host: the address itself is the label, never an empty one.
8
+ return { href, label: NETWORKS[host] ?? (host || href) }
9
+ }
@@ -6,6 +6,8 @@ import { tv } from 'tailwind-variants'
6
6
  import KitImage from '../_shared/KitImage.astro'
7
7
  import Section from '../_shared/Section.astro'
8
8
  import { imageOf, type ImageSource } from '../_shared/image'
9
+ import { measuredGrid } from '../_shared/measured'
10
+ import type { MeasuredStyle } from '../_shared/types'
9
11
 
10
12
  export interface TestimonialItem {
11
13
  quote: string
@@ -14,6 +16,11 @@ export interface TestimonialItem {
14
16
  /** The portrait's address (or, until 0.7, an `ImageInput`). */
15
17
  avatar?: ImageSource | undefined
16
18
  avatarAlt?: string | undefined
19
+ /** Stars out of 5 (0 or absent: none shown). */
20
+ rating?: number | undefined
21
+ /** A company logo beside the name. */
22
+ logo?: ImageSource | undefined
23
+ logoAlt?: string | undefined
17
24
  }
18
25
 
19
26
  const testimonial = tv({
@@ -23,6 +30,10 @@ const testimonial = tv({
23
30
  card: 'flex h-full flex-col gap-6',
24
31
  quote: 'text-pretty',
25
32
  person: 'mt-auto flex items-center gap-3 not-italic',
33
+ stars: 'flex gap-0.5 text-accent-text',
34
+ glyph: 'block text-5xl leading-none opacity-40 select-none',
35
+ avatar: 'size-12 shrink-0 rounded-full object-cover',
36
+ logo: 'ms-auto h-6 w-auto max-w-24 object-contain opacity-80',
26
37
  name: 'font-semibold',
27
38
  role: 'text-sm opacity-75',
28
39
  },
@@ -30,8 +41,17 @@ const testimonial = tv({
30
41
  layout: {
31
42
  single: { heading: 'text-center', list: 'mx-auto max-w-3xl', card: 'items-center text-center', quote: 'text-2xl leading-snug font-medium md:text-3xl', person: 'mt-0' },
32
43
  grid: { list: 'md:grid-cols-2 lg:grid-cols-3', card: 'rounded-card border border-line p-6', quote: 'text-base' },
44
+ // Native horizontal scroll with snap points; the next card peeks at the edge. No buttons, no script.
45
+ row: { list: 'flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-ps-4 pb-4 focus-visible:outline-2 focus-visible:outline-offset-4', card: 'w-[85%] shrink-0 snap-start rounded-card border border-line p-6 sm:w-[45%] lg:w-[30%]', quote: 'text-base' },
46
+ // CSS columns for four or more quotes of different lengths.
47
+ masonry: { list: 'block columns-1 gap-6 space-y-0 md:columns-2 lg:columns-3', card: 'mb-6 h-full break-inside-avoid rounded-card border border-line p-6', quote: 'text-base' },
33
48
  },
34
- tone: { default: {}, muted: { card: 'bg-surface' }, inverse: { card: 'border-surface/20' }, accent: { card: 'border-accent-ink/30', role: 'opacity-100' } },
49
+ avatarSize: { sm: { avatar: 'size-9' }, md: {}, lg: { avatar: 'size-16' } },
50
+ // start: the portrait before the name. top: above the quote.
51
+ avatarPlace: { start: {}, top: {} },
52
+ // marks: the quotation marks around the text, as the source writes it. glyph: one large decorative mark above.
53
+ quoteMark: { marks: {}, glyph: {}, none: {} },
54
+ tone: { default: {}, muted: { card: 'bg-surface' }, inverse: { card: 'border-surface/20' }, accent: { card: 'border-accent-ink/30', role: 'opacity-100', logo: 'opacity-100' } },
35
55
  // The quote's size; `auto` is the layout's own (body size in a grid, large for a single quote).
36
56
  quoteSize: {
37
57
  auto: {},
@@ -44,47 +64,77 @@ const testimonial = tv({
44
64
  frame: { card: {}, plain: {} },
45
65
  },
46
66
  compoundVariants: [
47
- { layout: 'grid', tone: 'muted', class: { card: 'border-transparent text-ink' } },
67
+ { layout: ['grid', 'row', 'masonry'], tone: 'muted', class: { card: 'border-transparent text-ink' } },
48
68
  { frame: 'plain', class: { card: 'rounded-none border-0 bg-transparent p-0' } },
49
69
  ],
50
- defaultVariants: { layout: 'grid', tone: 'default', quoteSize: 'auto', frame: 'card' },
70
+ defaultVariants: { layout: 'grid', tone: 'default', quoteSize: 'auto', frame: 'card', avatarSize: 'md', avatarPlace: 'start', quoteMark: 'marks' },
51
71
  })
52
72
 
53
73
  interface Props {
54
74
  items: TestimonialItem[]
55
75
  heading?: string | undefined
56
- layout?: 'single' | 'grid' | undefined
76
+ layout?: 'single' | 'grid' | 'row' | 'masonry' | undefined
57
77
  quoteSize?: 'auto' | 'base' | 'lg' | 'xl' | undefined
58
78
  frame?: 'card' | 'plain' | undefined
79
+ avatarSize?: 'sm' | 'md' | 'lg' | undefined
80
+ avatarPlace?: 'start' | 'top' | undefined
81
+ quoteMark?: 'marks' | 'glyph' | 'none' | undefined
82
+ /** The stars' spoken text; `{rating}` stands for the number. */
83
+ ratingLabel?: string | undefined
84
+ /** The scrolling row's name for assistive technology, when there is no heading to name it (layout row). */
85
+ label?: string | undefined
86
+ /** 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. */
87
+ measured?: MeasuredStyle | undefined
59
88
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
60
89
  id?: string | undefined
61
90
  }
62
91
 
63
- const { items, heading, layout, quoteSize, frame, tone, id } = Astro.props
64
- const s = testimonial({ layout, quoteSize, frame, tone })
92
+ const { items, heading, layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark = 'marks', ratingLabel = '{rating} out of 5 stars', label = 'Testimonials', tone, measured, id } = Astro.props
93
+ const s = testimonial({ layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark, tone })
65
94
  const headingId = heading && id ? `${id}-title` : undefined
66
95
  // Two quotes share the row: a third column would stand empty beside them and narrow both (IP-4 elementor launch G9).
67
- const list = s.list(items.length === 2 && (layout ?? 'grid') === 'grid' ? { class: 'lg:grid-cols-2' } : {})
96
+ const measuredList = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
97
+ const list = s.list({ class: measuredList ?? (items.length === 2 && (layout ?? 'grid') === 'grid' ? 'lg:grid-cols-2' : undefined) })
68
98
  // The quotation marks are drawn, not written: the quote's text is the source's words, read once. A quote the source
69
99
  // already wrote inside marks keeps its own.
70
100
  const quoted = (text: string) => /^\s*["'“‘„«‹「『]/.test(text)
101
+ // The real number is what is said ("4.8 out of 5"); the stars drawn follow it in halves.
102
+ const stars = (item: TestimonialItem) => Math.min(5, Math.max(0, item.rating ?? 0))
103
+ const spoken = (rating: number) => String(Math.round(rating * 10) / 10)
104
+ // A row scrolls: its box is focusable and named, so a keyboard reaches the cards beyond the edge.
105
+ const scrolls = layout === 'row'
71
106
  ---
72
107
  {items.length > 0 && (
73
- <Section tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
108
+ <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
74
109
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
75
- <div class={list}>
76
- {items.map(item => (
77
- <figure class={s.card()}>
78
- <blockquote class={s.quote()}><p class:list={[!quoted(item.quote) && "before:content-[open-quote] after:content-[close-quote]"]}>{item.quote}</p></blockquote>
79
- <figcaption class={s.person()}>
80
- {imageOf(item.avatar, item.avatarAlt) && <KitImage image={imageOf(item.avatar, item.avatarAlt)!} sizes="48px" class="size-12 shrink-0 rounded-full object-cover" />}
81
- <span class="flex flex-col text-start">
82
- <span class={s.name()}>{item.name}</span>
83
- {item.role && <span class={s.role()}>{item.role}</span>}
84
- </span>
85
- </figcaption>
86
- </figure>
87
- ))}
110
+ <div class={list} tabindex={scrolls ? 0 : undefined} role={scrolls ? 'region' : undefined} aria-labelledby={scrolls && heading ? headingId : undefined} aria-label={scrolls && !heading ? label : undefined}>
111
+ {items.map((item) => {
112
+ const avatar = imageOf(item.avatar, item.avatarAlt)
113
+ const logo = imageOf(item.logo, item.logoAlt)
114
+ const rating = stars(item)
115
+ return (
116
+ <figure class={s.card()}>
117
+ {avatar && avatarPlace === 'top' && <KitImage image={avatar} sizes="64px" class={s.avatar()} />}
118
+ {rating > 0 && (
119
+ <span class={s.stars()} role="img" aria-label={ratingLabel.replace('{rating}', spoken(rating))}>
120
+ {[1, 2, 3, 4, 5].map(n => (
121
+ <svg viewBox="0 0 24 24" class:list={['size-5', rating < n && (rating >= n - 0.5 ? 'opacity-60' : 'opacity-25')]} fill="currentColor" aria-hidden="true"><path d="m12 2 3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z" /></svg>
122
+ ))}
123
+ </span>
124
+ )}
125
+ {quoteMark === 'glyph' && <span class:list={[s.glyph(), "before:content-['“']"]} aria-hidden="true"></span>}
126
+ <blockquote class={s.quote()}><p class:list={[quoteMark === 'marks' && !quoted(item.quote) && "before:content-[open-quote] after:content-[close-quote]"]}>{item.quote}</p></blockquote>
127
+ <figcaption class={s.person()}>
128
+ {avatar && avatarPlace !== 'top' && <KitImage image={avatar} sizes="64px" class={s.avatar()} />}
129
+ <span class="flex flex-col text-start">
130
+ <span class={s.name()}>{item.name}</span>
131
+ {item.role && <span class={s.role()}>{item.role}</span>}
132
+ </span>
133
+ {logo && <KitImage image={logo} sizes="96px" class={s.logo()} />}
134
+ </figcaption>
135
+ </figure>
136
+ )
137
+ })}
88
138
  </div>
89
139
  </Section>
90
140
  )}
@@ -46,5 +46,78 @@
46
46
  {
47
47
  "name": "grid-source-quoted",
48
48
  "props": { "items": [{ "quote": "“We set it up before lunch.”", "name": "Grace Lin", "role": "Head of Support, Fernhill" }, { "quote": "People just read the page.", "name": "Omar Haddad", "role": "Support lead" }] }
49
+ },
50
+ {
51
+ "name": "row",
52
+ "props": {
53
+ "heading": "Kind words",
54
+ "items": [
55
+ { "quote": "The only travel site I read to the end. Every guide feels written by someone who stayed.", "name": "Ayşe Demir", "role": "Reader since 2019", "avatar": { "src": "/kit-fixtures/avatar-1.svg", "alt": "", "width": 96, "height": 96 }, "rating": 5 },
56
+ { "quote": "Their Lisbon guide saved our trip.", "name": "Tom Becker", "avatar": { "src": "/kit-fixtures/avatar-2.svg", "alt": "", "width": 96, "height": 96 }, "rating": 4 },
57
+ { "quote": "Honest, slow, and beautifully photographed. I recommend it to every friend who asks where to go next.", "name": "Mei Tanaka", "role": "Photographer", "avatar": { "src": "/kit-fixtures/avatar-3.svg", "alt": "", "width": 96, "height": 96 }, "rating": 3 },
58
+ { "quote": "The only travel site I read to the end. Every guide feels written by someone who stayed.", "name": "Ayşe Demir", "role": "Reader since 2019", "avatar": { "src": "/kit-fixtures/avatar-1.svg", "alt": "", "width": 96, "height": 96 }, "rating": 5 },
59
+ { "quote": "Their Lisbon guide saved our trip.", "name": "Tom Becker", "avatar": { "src": "/kit-fixtures/avatar-2.svg", "alt": "", "width": 96, "height": 96 }, "rating": 4 },
60
+ { "quote": "Honest, slow, and beautifully photographed. I recommend it to every friend who asks where to go next.", "name": "Mei Tanaka", "role": "Photographer", "avatar": { "src": "/kit-fixtures/avatar-3.svg", "alt": "", "width": 96, "height": 96 }, "rating": 3 }
61
+ ],
62
+ "layout": "row"
63
+ }
64
+ },
65
+ {
66
+ "name": "masonry-sm-top",
67
+ "props": {
68
+ "heading": "Kind words",
69
+ "items": [
70
+ { "quote": "The only travel site I read to the end. Every guide feels written by someone who stayed.", "name": "Ayşe Demir", "role": "Reader since 2019", "avatar": { "src": "/kit-fixtures/avatar-1.svg", "alt": "", "width": 96, "height": 96 } },
71
+ { "quote": "Their Lisbon guide saved our trip.", "name": "Tom Becker", "avatar": { "src": "/kit-fixtures/avatar-2.svg", "alt": "", "width": 96, "height": 96 } },
72
+ { "quote": "Honest, slow, and beautifully photographed. I recommend it to every friend who asks where to go next.", "name": "Mei Tanaka", "role": "Photographer", "avatar": { "src": "/kit-fixtures/avatar-3.svg", "alt": "", "width": 96, "height": 96 } },
73
+ { "quote": "The only travel site I read to the end. Every guide feels written by someone who stayed.", "name": "Ayşe Demir", "role": "Reader since 2019", "avatar": { "src": "/kit-fixtures/avatar-1.svg", "alt": "", "width": 96, "height": 96 } },
74
+ { "quote": "Their Lisbon guide saved our trip.", "name": "Tom Becker", "avatar": { "src": "/kit-fixtures/avatar-2.svg", "alt": "", "width": 96, "height": 96 } },
75
+ { "quote": "Honest, slow, and beautifully photographed. I recommend it to every friend who asks where to go next.", "name": "Mei Tanaka", "role": "Photographer", "avatar": { "src": "/kit-fixtures/avatar-3.svg", "alt": "", "width": 96, "height": 96 } }
76
+ ],
77
+ "layout": "masonry",
78
+ "avatarSize": "sm",
79
+ "avatarPlace": "top"
80
+ }
81
+ },
82
+ {
83
+ "name": "glyph-large-rated",
84
+ "props": { "heading": "Kind words", "items": [{ "quote": "The only travel site I read to the end. Every guide feels written by someone who stayed.", "name": "Ayşe Demir", "role": "Reader since 2019", "avatar": { "src": "/kit-fixtures/avatar-1.svg", "alt": "", "width": 96, "height": 96 }, "rating": 5 }], "layout": "single", "quoteMark": "glyph", "avatarSize": "lg" }
85
+ },
86
+ {
87
+ "name": "grid-none-logos",
88
+ "props": {
89
+ "heading": "Kind words",
90
+ "items": [
91
+ { "quote": "The only travel site I read to the end. Every guide feels written by someone who stayed.", "name": "Ayşe Demir", "role": "Reader since 2019", "avatar": { "src": "/kit-fixtures/avatar-1.svg", "alt": "", "width": 96, "height": 96 }, "logo": "/kit-fixtures/logo.svg", "logoAlt": "Acme" },
92
+ { "quote": "Their Lisbon guide saved our trip.", "name": "Tom Becker", "avatar": { "src": "/kit-fixtures/avatar-2.svg", "alt": "", "width": 96, "height": 96 }, "logo": "/kit-fixtures/logo.svg", "logoAlt": "Acme" },
93
+ { "quote": "Honest, slow, and beautifully photographed. I recommend it to every friend who asks where to go next.", "name": "Mei Tanaka", "role": "Photographer", "avatar": { "src": "/kit-fixtures/avatar-3.svg", "alt": "", "width": 96, "height": 96 }, "logo": "/kit-fixtures/logo.svg", "logoAlt": "Acme" }
94
+ ],
95
+ "quoteMark": "none",
96
+ "tone": "muted"
97
+ }
98
+ },
99
+ {
100
+ "name": "measured-columns",
101
+ "props": {
102
+ "heading": "What readers say",
103
+ "id": "readers",
104
+ "items": [
105
+ { "quote": "The only travel site I read to the end. Every guide feels written by someone who stayed.", "name": "Ayşe Demir", "role": "Reader since 2019", "avatar": { "src": "/kit-fixtures/avatar-1.svg", "alt": "", "width": 96, "height": 96 } },
106
+ { "quote": "Their Lisbon guide saved our trip.", "name": "Tom Becker", "avatar": { "src": "/kit-fixtures/avatar-2.svg", "alt": "", "width": 96, "height": 96 } },
107
+ { "quote": "Honest, slow, and beautifully photographed. I recommend it to every friend who asks where to go next.", "name": "Mei Tanaka", "role": "Photographer", "avatar": { "src": "/kit-fixtures/avatar-3.svg", "alt": "", "width": 96, "height": 96 } }
108
+ ],
109
+ "measured": {
110
+ "columns": {
111
+ "390": 1,
112
+ "768": 2,
113
+ "1280": 3
114
+ },
115
+ "gap": 16,
116
+ "bg": {
117
+ "color": "#f1f5f9"
118
+ },
119
+ "tone": "light"
120
+ }
121
+ }
49
122
  }
50
123
  ]