@contentrain/astro-kit 0.30.0 → 0.31.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 (35) hide show
  1. package/README.md +25 -2
  2. package/catalog.json +1043 -7
  3. package/components/_shared/Section.astro +18 -3
  4. package/components/_shared/measured.ts +142 -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/meta.json +1 -1
  15. package/components/cta/meta.json +1 -1
  16. package/components/faq/meta.json +1 -1
  17. package/components/feature-list/FeatureList.astro +7 -3
  18. package/components/feature-list/fixtures.json +2 -1
  19. package/components/feature-list/meta.json +113 -1
  20. package/components/gallery/meta.json +1 -1
  21. package/components/hero/Hero.astro +14 -8
  22. package/components/hero/fixtures.json +2 -1
  23. package/components/hero/meta.json +111 -0
  24. package/components/logo-cloud/meta.json +1 -0
  25. package/components/newsletter/Newsletter.astro +5 -1
  26. package/components/newsletter/meta.json +113 -1
  27. package/components/pricing/meta.json +1 -0
  28. package/components/split/meta.json +1 -0
  29. package/components/stats/meta.json +3 -1
  30. package/components/steps/meta.json +1 -0
  31. package/components/tabs/Tabs.astro +5 -2
  32. package/components/tabs/meta.json +2 -1
  33. package/components/team/meta.json +1 -0
  34. package/components/testimonial/meta.json +1 -0
  35. package/package.json +2 -2
@@ -4,7 +4,7 @@
4
4
  "description": "A call to action: a short heading, an optional line of text and one or two buttons — across the whole section, or as a card inside it.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Button.astro", "Section.astro", "types.ts"],
7
+ "shared": ["Button.astro", "Section.astro", "measured.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["inline", "centered", "stacked"], "default": "inline", "description": "inline: text left, buttons right on wide screens. centered: everything centred. stacked: heading, text and buttons in one column at the start edge on every width." },
10
10
  "frame": { "options": ["full", "card"], "default": "full", "description": "full: the tone fills the section. card: a rounded box in a plain section carries the tone." },
@@ -4,7 +4,7 @@
4
4
  "description": "Questions and answers as native <details> disclosures (no script), in lines or cards; optionally one open at a time, and optionally with FAQPage structured data for search engines and AI answers.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Section.astro"],
7
+ "shared": ["Section.astro", "measured.ts", "types.ts"],
8
8
  "variants": {
9
9
  "style": { "options": ["lines", "cards", "plain"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box. plain: the browser's own disclosure triangle without rules or boxes, questions at the body size (WordPress's details block)." },
10
10
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." },
@@ -3,6 +3,8 @@
3
3
  // the feature's icon or a shared check, under an optional heading and intro.
4
4
  import { tv } from 'tailwind-variants'
5
5
  import Section, { type Tone } from '../_shared/Section.astro'
6
+ import { measuredGrid } from '../_shared/measured'
7
+ import type { MeasuredStyle } from '../_shared/types'
6
8
 
7
9
  const list = tv({
8
10
  slots: {
@@ -58,23 +60,25 @@ interface Props {
58
60
  style?: 'icon' | 'check' | 'list' | undefined
59
61
  tone?: Tone | undefined
60
62
  id?: string | undefined
63
+ /** 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. */
64
+ measured?: MeasuredStyle | undefined
61
65
  }
62
66
 
63
- const { heading, intro, items, columns, style = 'icon', tone, id } = Astro.props
67
+ const { heading, intro, items, columns, style = 'icon', tone, id, measured } = Astro.props
64
68
  const s = list({ columns, style, tone })
65
69
  const headingId = heading && id ? `${id}-title` : undefined
66
70
  const pathOf = (item: Item) => (style !== 'check' && item.icon && icons[item.icon]) || icons.check
67
71
  // A plain list's items are not headings; otherwise one level under the section heading, or the section's when there is none.
68
72
  const Title = style === 'list' ? 'span' : heading ? 'h3' : 'h2'
69
73
  ---
70
- <Section tone={tone} id={id} aria-labelledby={headingId}>
74
+ <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
71
75
  {(heading || intro) && (
72
76
  <header class="mb-10 flex max-w-2xl flex-col gap-3">
73
77
  {heading && <h2 id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{heading}</h2>}
74
78
  {intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']} data-kit-text>{intro}</p>}
75
79
  </header>
76
80
  )}
77
- <ul class={s.grid()}>
81
+ <ul class={s.grid({ class: measuredGrid(measured) })}>
78
82
  {items.map(item => (
79
83
  <li class={s.item()}>
80
84
  <span class={s.mark()} aria-hidden="true">
@@ -150,5 +150,6 @@
150
150
  ],
151
151
  "style": "list"
152
152
  }
153
- }
153
+ },
154
+ {"name": "measured-gap", "props": {"heading": "Why us", "measured": {"columns": {"390": 1, "768": 2, "1280": 3}, "gap": 16}, "items": [{"title": "Fast", "text": "Static HTML."}, {"title": "Kept", "text": "Every address survives."}, {"title": "Editable", "text": "Content in Studio."}]}}
154
155
  ]
@@ -5,7 +5,9 @@
5
5
  "category": "section",
6
6
  "uses": [],
7
7
  "shared": [
8
- "Section.astro"
8
+ "Section.astro",
9
+ "measured.ts",
10
+ "types.ts"
9
11
  ],
10
12
  "variants": {
11
13
  "columns": {
@@ -89,6 +91,116 @@
89
91
  "type": "string",
90
92
  "content": false,
91
93
  "description": "Anchor id of the section; the heading gets `<id>-title`."
94
+ },
95
+ "measured": {
96
+ "type": "object",
97
+ "content": false,
98
+ "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.",
99
+ "fields": {
100
+ "containerPx": {
101
+ "type": "integer",
102
+ "min": 0,
103
+ "max": 2560,
104
+ "description": "The frame's content width."
105
+ },
106
+ "padY": {
107
+ "type": "integer",
108
+ "min": 0,
109
+ "max": 400,
110
+ "description": "Top and bottom padding."
111
+ },
112
+ "padX": {
113
+ "type": "integer",
114
+ "min": 0,
115
+ "max": 200,
116
+ "description": "The frame's side padding (gutter)."
117
+ },
118
+ "bg": {
119
+ "type": "object",
120
+ "description": "The section's background.",
121
+ "fields": {
122
+ "color": {
123
+ "type": "string",
124
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
125
+ },
126
+ "image": {
127
+ "type": "string",
128
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
129
+ },
130
+ "overlay": {
131
+ "type": "string",
132
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
133
+ },
134
+ "size": {
135
+ "type": "string",
136
+ "description": "cover, contain or auto; anything else draws as cover."
137
+ },
138
+ "position": {
139
+ "type": "string",
140
+ "description": "One or two of center/top/bottom/left/right or percentages."
141
+ }
142
+ }
143
+ },
144
+ "columns": {
145
+ "type": "object",
146
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
147
+ "fields": {
148
+ "390": {
149
+ "type": "integer",
150
+ "min": 1,
151
+ "max": 6,
152
+ "description": "Phone."
153
+ },
154
+ "768": {
155
+ "type": "integer",
156
+ "min": 1,
157
+ "max": 6,
158
+ "description": "Tablet."
159
+ },
160
+ "1280": {
161
+ "type": "integer",
162
+ "min": 1,
163
+ "max": 6,
164
+ "description": "Desktop."
165
+ }
166
+ }
167
+ },
168
+ "gap": {
169
+ "type": "integer",
170
+ "min": 0,
171
+ "max": 200,
172
+ "description": "Gap between items."
173
+ },
174
+ "tone": {
175
+ "type": "select",
176
+ "options": [
177
+ "light",
178
+ "dark"
179
+ ],
180
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
181
+ },
182
+ "align": {
183
+ "type": "select",
184
+ "options": [
185
+ "start",
186
+ "center",
187
+ "end"
188
+ ],
189
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
190
+ },
191
+ "radius": {
192
+ "type": "integer",
193
+ "min": 0,
194
+ "max": 200,
195
+ "description": "Card and item corner radius."
196
+ },
197
+ "minHeight": {
198
+ "type": "integer",
199
+ "min": 0,
200
+ "max": 2000,
201
+ "description": "The section's minimum height."
202
+ }
203
+ }
92
204
  }
93
205
  },
94
206
  "slots": {},
@@ -4,7 +4,7 @@
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": ["KitImage.astro", "Section.astro", "image.ts", "measured.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["grid", "masonry"], "default": "grid", "description": "grid: square crops in rows. masonry: columns that keep each image's shape." },
10
10
  "columns": { "options": ["2", "3", "4"], "default": "3", "description": "Columns on wide screens; phones show two." },
@@ -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": {},
@@ -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": {
@@ -10,6 +10,7 @@
10
10
  // of placing it.
11
11
  import { tv } from 'tailwind-variants'
12
12
  import Section from '../_shared/Section.astro'
13
+ import type { MeasuredStyle } from '../_shared/types'
13
14
  import { newsletterForm } from './newsletter'
14
15
 
15
16
  const newsletter = tv({
@@ -64,12 +65,15 @@ interface Props {
64
65
  layout?: 'inline' | 'stacked' | undefined
65
66
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
66
67
  id?: string | undefined
68
+ /** 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. */
69
+ measured?: MeasuredStyle | undefined
67
70
  }
68
71
 
69
72
  const {
70
73
  action, provider = 'mailchimp', emailName, askName = false, heading, intro, note,
71
74
  emailLabel = 'Email address', nameLabel = 'First name', submitLabel = 'Subscribe', newTab = true,
72
75
  layout, tone = 'muted', id = 'newsletter',
76
+ measured,
73
77
  } = Astro.props
74
78
  const form = newsletterForm(provider, action, { email: emailName }, Astro.site)
75
79
  const s = newsletter({ layout, tone })
@@ -78,7 +82,7 @@ const headingId = heading ? `${id}-title` : undefined
78
82
  const formRel: Record<string, string> = newTab ? { rel: 'noopener noreferrer' } : {}
79
83
  ---
80
84
  {form && (
81
- <Section tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
85
+ <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
82
86
  <div class={s.box()}>
83
87
  {(heading || intro) && (
84
88
  <div class={s.copy()}>
@@ -5,7 +5,9 @@
5
5
  "category": "section",
6
6
  "uses": [],
7
7
  "shared": [
8
- "Section.astro"
8
+ "Section.astro",
9
+ "measured.ts",
10
+ "types.ts"
9
11
  ],
10
12
  "variants": {
11
13
  "layout": {
@@ -99,6 +101,116 @@
99
101
  "type": "string",
100
102
  "content": false,
101
103
  "description": "Section id, unique per page (two sign-ups on one page need two ids): the heading gets `<id>-title`, the inputs `<id>-email`/`<id>-name`."
104
+ },
105
+ "measured": {
106
+ "type": "object",
107
+ "content": false,
108
+ "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.",
109
+ "fields": {
110
+ "containerPx": {
111
+ "type": "integer",
112
+ "min": 0,
113
+ "max": 2560,
114
+ "description": "The frame's content width."
115
+ },
116
+ "padY": {
117
+ "type": "integer",
118
+ "min": 0,
119
+ "max": 400,
120
+ "description": "Top and bottom padding."
121
+ },
122
+ "padX": {
123
+ "type": "integer",
124
+ "min": 0,
125
+ "max": 200,
126
+ "description": "The frame's side padding (gutter)."
127
+ },
128
+ "bg": {
129
+ "type": "object",
130
+ "description": "The section's background.",
131
+ "fields": {
132
+ "color": {
133
+ "type": "string",
134
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
135
+ },
136
+ "image": {
137
+ "type": "string",
138
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
139
+ },
140
+ "overlay": {
141
+ "type": "string",
142
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
143
+ },
144
+ "size": {
145
+ "type": "string",
146
+ "description": "cover, contain or auto; anything else draws as cover."
147
+ },
148
+ "position": {
149
+ "type": "string",
150
+ "description": "One or two of center/top/bottom/left/right or percentages."
151
+ }
152
+ }
153
+ },
154
+ "columns": {
155
+ "type": "object",
156
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
157
+ "fields": {
158
+ "390": {
159
+ "type": "integer",
160
+ "min": 1,
161
+ "max": 6,
162
+ "description": "Phone."
163
+ },
164
+ "768": {
165
+ "type": "integer",
166
+ "min": 1,
167
+ "max": 6,
168
+ "description": "Tablet."
169
+ },
170
+ "1280": {
171
+ "type": "integer",
172
+ "min": 1,
173
+ "max": 6,
174
+ "description": "Desktop."
175
+ }
176
+ }
177
+ },
178
+ "gap": {
179
+ "type": "integer",
180
+ "min": 0,
181
+ "max": 200,
182
+ "description": "Gap between items."
183
+ },
184
+ "tone": {
185
+ "type": "select",
186
+ "options": [
187
+ "light",
188
+ "dark"
189
+ ],
190
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
191
+ },
192
+ "align": {
193
+ "type": "select",
194
+ "options": [
195
+ "start",
196
+ "center",
197
+ "end"
198
+ ],
199
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
200
+ },
201
+ "radius": {
202
+ "type": "integer",
203
+ "min": 0,
204
+ "max": 200,
205
+ "description": "Card and item corner radius."
206
+ },
207
+ "minHeight": {
208
+ "type": "integer",
209
+ "min": 0,
210
+ "max": 2000,
211
+ "description": "The section's minimum height."
212
+ }
213
+ }
102
214
  }
103
215
  },
104
216
  "slots": {},
@@ -7,6 +7,7 @@
7
7
  "shared": [
8
8
  "Button.astro",
9
9
  "Section.astro",
10
+ "measured.ts",
10
11
  "types.ts"
11
12
  ],
12
13
  "variants": {
@@ -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": {
@@ -5,7 +5,9 @@
5
5
  "category": "section",
6
6
  "uses": [],
7
7
  "shared": [
8
- "Section.astro"
8
+ "Section.astro",
9
+ "measured.ts",
10
+ "types.ts"
9
11
  ],
10
12
  "variants": {
11
13
  "columns": {
@@ -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": {
@@ -5,6 +5,7 @@
5
5
  // the same markup into tabs.
6
6
  import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
+ import type { MeasuredStyle } from '../_shared/types'
8
9
 
9
10
  export interface TabItem {
10
11
  label: string
@@ -47,9 +48,11 @@ interface Props {
47
48
  orientation?: 'horizontal' | 'vertical' | undefined
48
49
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
49
50
  id?: string | undefined
51
+ /** 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. */
52
+ measured?: MeasuredStyle | undefined
50
53
  }
51
54
 
52
- const { items, heading, label, style, orientation = 'horizontal', tone, id = 'tabs' } = Astro.props
55
+ const { items, heading, label, style, orientation = 'horizontal', tone, id = 'tabs', measured } = Astro.props
53
56
  const s = tabs({ style, orientation, tone })
54
57
  const headingId = heading ? `${id}-title` : undefined
55
58
  // One level under the section heading, or the section's heading when there is none.
@@ -57,7 +60,7 @@ const PanelHeading = heading ? 'h3' : 'h2'
57
60
  const value = (i: number) => `tab-${i + 1}`
58
61
  ---
59
62
  {items.length > 0 && (
60
- <Section tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
63
+ <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
61
64
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
62
65
  <div class={s.root()} data-kit-tabs={id} data-orientation={orientation} data-label={label ?? heading ?? undefined}>
63
66
  <div class={s.list()} data-part="list">