@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
@@ -3,6 +3,8 @@
3
3
  // section component renders inside one, so sections stack evenly whatever
4
4
  // their order.
5
5
  import { tv } from 'tailwind-variants'
6
+ import { measuredFrame } from './measured'
7
+ import type { MeasuredStyle } from './types'
6
8
 
7
9
  const section = tv({
8
10
  base: '',
@@ -39,10 +41,23 @@ interface Props {
39
41
  id?: string | undefined
40
42
  class?: string | undefined
41
43
  'aria-labelledby'?: string | undefined
44
+ /** The source section's measured style (`MeasuredStyle`): frame width, padding, fill, image, columns, gap. Wins over `tone`. */
45
+ measured?: MeasuredStyle | undefined
42
46
  }
43
47
 
44
- const { tone, spacing, width = 'page', as: Tag = 'section', id, class: className, 'aria-labelledby': labelledBy } = Astro.props
48
+ const { tone: given, spacing, width = 'page', as: Tag = 'section', id, class: className, 'aria-labelledby': labelledBy, measured } = Astro.props
49
+ const m = measuredFrame(measured)
50
+ // A measured fill is the section's own colour, its text set by the measured tone; a tone alone picks the kit's band.
51
+ const tone = m.color ? 'none' : m.tone === 'dark' ? 'inverse' : m.tone === 'light' ? 'default' : given
52
+ const text = m.color ? (m.tone === 'dark' ? 'text-surface [--color-accent-text:var(--color-accent-text-inverse)]' : 'text-ink') : undefined
53
+ const layered = m.image || m.overlay ? 'relative isolate overflow-hidden' : undefined
54
+ const style = [m.color && `background-color: ${m.color}`, m.style].filter(Boolean).join('; ') || undefined
55
+ // The reading column reads its own width token.
56
+ const frameStyle = width === 'prose' ? m.frameStyle?.replace('--container-page:', '--container-prose:') : m.frameStyle
57
+ const dataTone = m.color ? (m.tone === 'dark' ? 'inverse' : 'default') : tone ?? 'default'
45
58
  ---
46
- <Tag id={id} aria-labelledby={labelledBy} class={section({ tone, spacing, class: className })} data-tone={tone ?? 'default'} data-kit-section data-kit-spacing={spacing ?? 'md'}>
47
- {width === 'none' ? <slot /> : <div class={width === 'prose' ? 'container-prose' : 'container-page'}><slot /></div>}
59
+ <Tag id={id} aria-labelledby={labelledBy} class={section({ tone, spacing, class: [text, layered, className].filter(Boolean).join(' ') || undefined })} style={style} data-tone={dataTone} data-kit-section data-kit-spacing={spacing ?? 'md'} data-kit-measured={measured ? '' : undefined}>
60
+ {m.image && <img src={m.image.src} alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full" style={`object-fit: ${m.image.fit}${m.image.position ? `; object-position: ${m.image.position}` : ''}`} />}
61
+ {m.overlay && <div class="absolute inset-0 -z-10" style={`background: ${m.overlay}`} aria-hidden="true" />}
62
+ {width === 'none' ? <slot /> : <div class={width === 'prose' ? 'container-prose' : 'container-page'} style={frameStyle}><slot /></div>}
48
63
  </Tag>
@@ -0,0 +1,142 @@
1
+ // A section's measured style (`MeasuredStyle`) as CSS the kit can trust. The values come from a page the
2
+ // migration measured: data, not CSS. Each one is checked against a small grammar and dropped when it does not
3
+ // fit, so nothing but a number, a colour or a known keyword reaches a style attribute.
4
+ import type { MeasuredStyle, MeasuredWidth } from './types'
5
+
6
+ const clamp = (value: unknown, min: number, max: number): number | undefined =>
7
+ typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max ? Math.round(value) : undefined
8
+
9
+ const HEX = /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i
10
+ const FUNCTIONAL = /^(?:rgba?|hsla?)\(\s*[\d.]+%?(?:\s*[,\s]\s*[\d.]+%?){2}(?:\s*[,/]\s*[\d.]+%?)?\s*\)$/i
11
+ const NAMED = /^(?:transparent|white|black)$/i
12
+
13
+ /** A colour the source wrote as hex, rgb(a) or hsl(a) (or transparent / white / black); anything else is dropped. */
14
+ const colorOf = (value: unknown): string | undefined =>
15
+ typeof value === 'string' && (HEX.test(value.trim()) || FUNCTIONAL.test(value.trim()) || NAMED.test(value.trim())) ? value.trim() : undefined
16
+
17
+ /** Whether a colour is dark enough to need light text (relative luminance under 0.4); unknown for named and hsl colours. */
18
+ const isDark = (color: string): boolean | undefined => {
19
+ const hex = HEX.test(color) ? color.slice(1) : undefined
20
+ const channels = hex
21
+ ? (hex.length <= 4 ? hex.slice(0, 3).split('').map(digit => Number.parseInt(digit + digit, 16)) : [0, 2, 4].map(i => Number.parseInt(hex.slice(i, i + 2), 16)))
22
+ : /^rgba?\(/i.test(color) ? color.match(/[\d.]+%?/g)!.slice(0, 3).map(v => (v.endsWith('%') ? Number.parseFloat(v) * 2.55 : Number.parseFloat(v))) : undefined
23
+ if (!channels) return /^black$/i.test(color) ? true : /^white$/i.test(color) ? false : undefined
24
+ const [r, g, b] = channels.map(v => { const c = v / 255; return c <= 0.039_28 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 })
25
+ return 0.2126 * r! + 0.7152 * g! + 0.0722 * b! < 0.4
26
+ }
27
+
28
+ const STOP = /^(.+?)(?:\s+\d{1,3}(?:\.\d+)?%){0,2}$/
29
+ const DIRECTION = /^(?:\d{1,3}(?:\.\d+)?deg|to (?:top|bottom|left|right)(?: (?:top|bottom|left|right))?|circle|ellipse)$/
30
+ /** Splits at commas outside parentheses: `rgba(0, 0, 0, .5) 0%, #000` → two stops. */
31
+ const topLevel = (inner: string): string[] => {
32
+ const parts: string[] = []
33
+ let depth = 0
34
+ let start = 0
35
+ for (let i = 0; i < inner.length; i++) {
36
+ if (inner[i] === '(') depth++
37
+ else if (inner[i] === ')') depth--
38
+ else if (inner[i] === ',' && depth === 0) { parts.push(inner.slice(start, i).trim()); start = i + 1 }
39
+ if (depth < 0) return []
40
+ }
41
+ return depth === 0 ? [...parts, inner.slice(start).trim()] : []
42
+ }
43
+ /** An overlay: a colour, or a linear/radial gradient whose every stop is a colour (with up to two percentages). */
44
+ const overlayOf = (value: unknown): string | undefined => {
45
+ if (typeof value !== 'string') return undefined
46
+ const text = value.trim()
47
+ const gradient = /^(linear|radial)-gradient\((.*)\)$/i.exec(text)
48
+ if (!gradient) return colorOf(text)
49
+ const parts = topLevel(gradient[2]!)
50
+ if (parts.length < 2) return undefined
51
+ const [first, ...rest] = DIRECTION.test(parts[0]!) ? parts : ['', ...parts]
52
+ const stops = first ? rest : parts
53
+ return stops.length >= 2 && stops.every(stop => colorOf(STOP.exec(stop)?.[1]) !== undefined) ? text : undefined
54
+ }
55
+
56
+ /** An image address: http(s) or root-relative, with nothing that could close a quote or a url(). */
57
+ const imageOf = (value: unknown): string | undefined =>
58
+ typeof value === 'string' && /^(?:https?:\/\/|\/(?!\/))/i.test(value) && !/["'()<>\\\s]/.test(value) ? value : undefined
59
+
60
+ const KEYWORD = /^(?:center|top|bottom|left|right)$/
61
+ const PERCENT = /^\d{1,3}(?:\.\d+)?%$/
62
+ /** `object-position` from the source's background position: up to two keywords or percentages. */
63
+ const positionOf = (value: unknown): string | undefined => {
64
+ if (typeof value !== 'string') return undefined
65
+ const parts = value.trim().toLowerCase().split(/\s+/)
66
+ return parts.length <= 2 && parts.every(part => KEYWORD.test(part) || PERCENT.test(part)) ? parts.join(' ') : undefined
67
+ }
68
+
69
+ /** `object-fit` from the source's background size. */
70
+ const fitOf = (value: unknown): 'cover' | 'contain' | 'none' | undefined =>
71
+ value === 'cover' || value === 'contain' ? value : value === 'auto' ? 'none' : undefined
72
+
73
+ /** Columns at each measured width, every width filled: 390 ?? 1; 768 ?? min(2, 1280) ?? 390; 1280 ?? 768 ?? 390. */
74
+ const columnsFor = (columns: MeasuredStyle['columns']): Record<MeasuredWidth, number> | undefined => {
75
+ if (!columns) return undefined
76
+ const at = (width: MeasuredWidth) => clamp(columns[width], 1, 6)
77
+ const [narrow, mid, wide] = [at(390), at(768), at(1280)]
78
+ if (narrow === undefined && mid === undefined && wide === undefined) return undefined
79
+ const phone = narrow ?? 1
80
+ const tablet = mid ?? (wide === undefined ? phone : Math.min(2, wide))
81
+ return { 390: phone, 768: tablet, 1280: wide ?? mid ?? phone }
82
+ }
83
+
84
+ /** What Section draws from a measured style. Empty (`{}`) when there is nothing to draw. */
85
+ export interface MeasuredFrame {
86
+ /** Inline style on the section element: custom properties and the section's own box. */
87
+ style?: string | undefined
88
+ /** Inline style on the frame: its width (`--container-page`; Section renames it for the reading column) and gutter. */
89
+ frameStyle?: string | undefined
90
+ /** The section's text colour, from what the band's text is set for. */
91
+ tone?: 'light' | 'dark' | undefined
92
+ /** The section's own background colour (it then draws no tone fill). */
93
+ color?: string | undefined
94
+ image?: { src: string, fit?: 'cover' | 'contain' | 'none' | undefined, position?: string | undefined } | undefined
95
+ /** A colour or gradient over the image (or over the fill); it is drawn as a layer's `background`. */
96
+ overlay?: string | undefined
97
+ }
98
+
99
+ export function measuredFrame(measured: MeasuredStyle | undefined): MeasuredFrame {
100
+ if (!measured) return {}
101
+ const own: string[] = []
102
+ const frame: string[] = []
103
+ const container = clamp(measured.containerPx, 240, 2560)
104
+ const padY = clamp(measured.padY, 0, 400)
105
+ const padX = clamp(measured.padX, 0, 200)
106
+ const gap = clamp(measured.gap, 0, 200)
107
+ const radius = clamp(measured.radius, 0, 200)
108
+ const minHeight = clamp(measured.minHeight, 0, 2000)
109
+ const columns = columnsFor(measured.columns)
110
+ if (columns) own.push(`--kit-cols-390: ${columns[390]}`, `--kit-cols-768: ${columns[768]}`, `--kit-cols-1280: ${columns[1280]}`)
111
+ if (gap !== undefined) own.push(`--kit-gap: ${gap}px`)
112
+ if (radius !== undefined) own.push(`--radius-card: ${radius}px`)
113
+ if (padY !== undefined) own.push(`padding-block: ${padY}px`)
114
+ if (minHeight) own.push(`min-height: ${minHeight}px`)
115
+ // The frame's width without its gutter, as `container-page` takes it; the gutter is the side padding.
116
+ if (container !== undefined) frame.push(`--container-page: ${container}px`)
117
+ if (padX !== undefined) frame.push(`--spacing-gutter: ${padX}px`)
118
+ const color = colorOf(measured.bg?.color)
119
+ const src = imageOf(measured.bg?.image)
120
+ // The text's tone as measured; with a fill and no tone, the fill's own lightness decides.
121
+ const given = measured.tone === 'light' || measured.tone === 'dark' ? measured.tone : undefined
122
+ const tone = given ?? (color === undefined ? undefined : isDark(color) ? 'dark' : 'light')
123
+ return {
124
+ style: own.length ? own.join('; ') : undefined,
125
+ frameStyle: frame.length ? frame.join('; ') : undefined,
126
+ tone,
127
+ color,
128
+ image: src ? { src, fit: fitOf(measured.bg?.size) ?? 'cover', position: positionOf(measured.bg?.position) } : undefined,
129
+ overlay: overlayOf(measured.bg?.overlay),
130
+ }
131
+ }
132
+
133
+ /**
134
+ * A list's grid at the measured columns and gap, or `undefined` to keep the component's own variant. The class
135
+ * names are written out in full so Tailwind finds them; Section sets the custom properties they read.
136
+ */
137
+ export function measuredGrid(measured: MeasuredStyle | undefined): string | undefined {
138
+ if (!measured) return undefined
139
+ const columns = columnsFor(measured.columns) ? 'grid-cols-[repeat(var(--kit-cols-390),minmax(0,1fr))] md:grid-cols-[repeat(var(--kit-cols-768),minmax(0,1fr))] lg:grid-cols-[repeat(var(--kit-cols-1280),minmax(0,1fr))]' : ''
140
+ const gap = clamp(measured.gap, 0, 200) !== undefined ? 'gap-[var(--kit-gap)] md:gap-[var(--kit-gap)]' : ''
141
+ return [columns, gap].filter(Boolean).join(' ') || undefined
142
+ }
@@ -25,3 +25,43 @@ export interface ActionInput extends LinkInput {
25
25
  export interface NavItem extends LinkInput {
26
26
  children?: NavItem[] | undefined
27
27
  }
28
+
29
+ /** The widths a migration measures a page at: phone, tablet, desktop (the kit's base, `md` and `lg`). */
30
+ export type MeasuredWidth = 390 | 768 | 1280
31
+
32
+ /**
33
+ * A section's style as measured on the source page (`MeasuredStyle` in `@contentrain/types`, same fields). Absent,
34
+ * a section takes the site's tokens; present, it wins over the tone and column variants. Values are pixels; Section
35
+ * checks each one and drops what is not a number, a colour or a known keyword.
36
+ */
37
+ export interface MeasuredStyle {
38
+ /** The frame's content width. */
39
+ containerPx?: number | undefined
40
+ /** The section's top and bottom padding. */
41
+ padY?: number | undefined
42
+ /** The frame's side padding (the gutter). */
43
+ padX?: number | undefined
44
+ bg?: {
45
+ /** Hex, rgb(a) or hsl(a). */
46
+ color?: string | undefined
47
+ /** http(s) or root-relative address; drawn as an image behind the content. */
48
+ image?: string | undefined
49
+ /** A colour or a linear/radial gradient laid over the image (or the fill). */
50
+ overlay?: string | undefined
51
+ /** `cover`, `contain` or `auto`; anything else draws as `cover`. */
52
+ size?: string | undefined
53
+ /** One or two of center/top/bottom/left/right or percentages. */
54
+ position?: string | undefined
55
+ } | undefined
56
+ /** Item columns per measured width; a missing width follows the narrower one. */
57
+ columns?: Partial<Record<MeasuredWidth, number>> | undefined
58
+ /** The gap between items. */
59
+ gap?: number | undefined
60
+ /** What the section's text is set for: `dark` is light text on a dark band. */
61
+ tone?: 'light' | 'dark' | undefined
62
+ /** Where the heading and lead sit across the frame; a component with its own `align` reads it when that is not set. */
63
+ align?: 'start' | 'center' | 'end' | undefined
64
+ /** Card and item corner radius. */
65
+ radius?: number | undefined
66
+ minHeight?: number | undefined
67
+ }
@@ -0,0 +1,132 @@
1
+ ---
2
+ // A section the migration has no closer component for: a band with an optional heading, lead, image, a grid of
3
+ // items and buttons. It draws whatever a measured source section holds, so an unrecognised section still reads as
4
+ // one of the site's sections and never as bare text.
5
+ import { tv } from 'tailwind-variants'
6
+ import Button from '../_shared/Button.astro'
7
+ import KitImage from '../_shared/KitImage.astro'
8
+ import Section, { type Tone } from '../_shared/Section.astro'
9
+ import { measuredGrid } from '../_shared/measured'
10
+ import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
11
+
12
+ const band = tv({
13
+ slots: {
14
+ grid: 'grid gap-10',
15
+ copy: 'flex max-w-3xl flex-col gap-4',
16
+ eyebrow: 'text-sm font-semibold tracking-wide text-accent-text uppercase',
17
+ heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
18
+ lead: 'text-lg text-pretty opacity-85',
19
+ media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
20
+ list: 'grid items-start gap-6 md:gap-8',
21
+ item: 'relative flex flex-col gap-3 rounded-[var(--radius-card)]',
22
+ itemMedia: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
23
+ itemTitle: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
24
+ itemValue: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight',
25
+ itemMeta: 'text-sm opacity-75',
26
+ itemText: 'opacity-80',
27
+ itemPoints: 'flex list-disc flex-col gap-1 ps-5 opacity-80',
28
+ actions: 'flex flex-wrap gap-3',
29
+ },
30
+ variants: {
31
+ // stack: everything in one column. split: the copy beside the image on wide screens.
32
+ layout: { stack: {}, split: { grid: 'md:grid-cols-2 md:items-center' } },
33
+ columns: {
34
+ '1': {},
35
+ '2': { list: 'sm:grid-cols-2' },
36
+ '3': { list: 'sm:grid-cols-2 md:grid-cols-3' },
37
+ '4': { list: 'sm:grid-cols-2 lg:grid-cols-4' },
38
+ },
39
+ align: {
40
+ start: {},
41
+ center: { copy: 'mx-auto items-center text-center', actions: 'justify-center', item: 'items-center text-center' },
42
+ },
43
+ // Accent backgrounds have the least contrast headroom: text on them is never dimmed.
44
+ tone: { default: {}, muted: {}, inverse: {}, accent: { lead: 'opacity-100', itemText: 'opacity-100', itemMeta: 'opacity-100', itemPoints: 'opacity-100', eyebrow: 'text-current' } },
45
+ },
46
+ defaultVariants: { layout: 'stack', columns: '3', align: 'start', tone: 'default' },
47
+ })
48
+
49
+ interface Item {
50
+ /** A figure drawn large above the title (a stat, a price). */
51
+ value?: string | undefined
52
+ title?: string | undefined
53
+ /** A short line under the title (a role, a date, a period). */
54
+ meta?: string | undefined
55
+ text?: string | undefined
56
+ href?: string | undefined
57
+ /** The link's own text, shown under the item; without it the whole item is the link. */
58
+ linkLabel?: string | undefined
59
+ /** The item's image address. */
60
+ media?: string | undefined
61
+ mediaAlt?: string | undefined
62
+ /** A small image (an icon) above the title. */
63
+ icon?: string | undefined
64
+ /** A short list under the text (features, facts). */
65
+ points?: string[] | undefined
66
+ }
67
+
68
+ interface Props {
69
+ heading?: string | undefined
70
+ eyebrow?: string | undefined
71
+ lead?: string | undefined
72
+ media?: ImageInput | undefined
73
+ items?: Item[] | undefined
74
+ actions?: ActionInput[] | undefined
75
+ /** Heading level of the section heading; item titles are one level below, or at this level when there is none. */
76
+ headingLevel?: 2 | 3 | undefined
77
+ layout?: 'stack' | 'split' | undefined
78
+ columns?: '1' | '2' | '3' | '4' | undefined
79
+ align?: 'start' | 'center' | undefined
80
+ tone?: Tone | undefined
81
+ measured?: MeasuredStyle | undefined
82
+ id?: string | undefined
83
+ }
84
+
85
+ const { heading, eyebrow, lead, media, items = [], actions = [], headingLevel = 2, layout, columns, align, tone, measured, id } = Astro.props
86
+ // The measured alignment when none is given (`end` has no band layout: it reads as `start`).
87
+ const aligned = align ?? (measured?.align === 'center' ? 'center' : undefined)
88
+ const s = band({ layout: media ? layout : 'stack', columns, align: aligned, tone })
89
+ const Heading = `h${headingLevel}` as 'h2' | 'h3'
90
+ const ItemHeading = `h${heading ? headingLevel + 1 : headingLevel}` as 'h2' | 'h3' | 'h4'
91
+ const headingId = heading ? (id ? `${id}-title` : `band-${heading.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`) : undefined
92
+ const hasCopy = Boolean(heading || eyebrow || lead || actions.length)
93
+ ---
94
+ <Section tone={tone} id={id} aria-labelledby={headingId} measured={measured}>
95
+ <div class="flex flex-col gap-10">
96
+ {(hasCopy || media) && (
97
+ <div class={s.grid()}>
98
+ {hasCopy && (
99
+ <div class={s.copy()}>
100
+ {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
101
+ {heading && <Heading id={headingId} class={s.heading()}>{heading}</Heading>}
102
+ {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
103
+ {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
104
+ </div>
105
+ )}
106
+ {media && <div class={s.media()}><KitImage image={media} sizes={layout === 'split' ? '(min-width: 768px) 50vw, 100vw' : '100vw'} class="h-auto w-full object-cover" /></div>}
107
+ </div>
108
+ )}
109
+ {items.length > 0 && (
110
+ <ul class={s.list({ class: measuredGrid(measured) })}>
111
+ {items.map(item => (
112
+ <li class={s.item()}>
113
+ {item.media && <div class={s.itemMedia()}><KitImage image={{ src: item.media, alt: item.mediaAlt ?? '' }} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
114
+ {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
115
+ {item.value && <p class={s.itemValue()}>{item.value}</p>}
116
+ {item.title && (
117
+ <ItemHeading class={s.itemTitle()}>
118
+ {item.href && !item.linkLabel
119
+ ? <a href={item.href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
120
+ : item.title}
121
+ </ItemHeading>
122
+ )}
123
+ {item.meta && <p class={s.itemMeta()}>{item.meta}</p>}
124
+ {item.text && <p class={s.itemText()} data-kit-text>{item.text}</p>}
125
+ {item.points && item.points.length > 0 && <ul class={s.itemPoints()}>{item.points.map(point => <li>{point}</li>)}</ul>}
126
+ {item.href && item.linkLabel && <a href={item.href} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
127
+ </li>
128
+ ))}
129
+ </ul>
130
+ )}
131
+ </div>
132
+ </Section>
@@ -0,0 +1,8 @@
1
+ [
2
+ {"name": "plain", "props": {"heading": "What we do", "lead": "Three things, done carefully.", "items": [{"title": "Design", "text": "Tokens and components."}, {"title": "Migration", "text": "Every address kept."}, {"title": "Speed", "text": "Static and fast."}], "tone": "inverse"}},
3
+ {"name": "split-actions", "props": {"layout": "split", "eyebrow": "About", "heading": "A studio of six", "lead": "We build sites people keep editing.", "media": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}, "actions": [{"label": "Contact us", "href": "/contact/"}], "tone": "accent"}},
4
+ {"name": "measured-dark-fill", "props": {"heading": "Our numbers", "align": "center", "measured": {"containerPx": 1140, "padY": 96, "padX": 24, "bg": {"color": "#0f172a"}, "columns": {"390": 1, "768": 2, "1280": 4}, "gap": 32, "radius": 8}, "items": [{"title": "12", "text": "years"}, {"title": "340", "text": "sites"}, {"title": "9", "text": "countries"}, {"title": "1", "text": "team"}], "columns": "4"}},
5
+ {"name": "measured-image-overlay", "props": {"heading": "Visit the farm", "lead": "Open every weekend.", "actions": [{"label": "Plan a visit", "href": "/visit/"}], "measured": {"padY": 120, "minHeight": 480, "tone": "dark", "bg": {"image": "/kit-fixtures/photo-2.svg", "overlay": "rgba(0, 0, 0, 0.55)", "size": "cover", "position": "center"}}, "columns": "1"}},
6
+ {"name": "items-links", "props": {"heading": "Services", "columns": "2", "tone": "muted", "items": [{"title": "Audits", "text": "Where a site loses readers.", "href": "/audits/", "linkLabel": "Read more", "media": "/kit-fixtures/photo-3.svg"}, {"title": "Hosting", "text": "Static hosting, managed.", "href": "/hosting/", "icon": "/kit-fixtures/logo.svg"}]}},
7
+ {"name": "values-points", "props": {"heading": "Plans", "columns": "3", "items": [{"value": "$9", "title": "Starter", "meta": "per month", "points": ["One site", "Email support"]}, {"value": "$29", "title": "Team", "meta": "per month", "points": ["Five sites", "Shared components"], "href": "/pricing/team/", "linkLabel": "Choose Team"}, {"value": "250+", "title": "Sites moved", "text": "Since 2019."}]}}
8
+ ]