@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.
- package/README.md +25 -2
- package/catalog.json +3305 -83
- package/components/_shared/Section.astro +18 -3
- package/components/_shared/measured.ts +152 -0
- package/components/_shared/types.ts +40 -0
- package/components/band/Band.astro +132 -0
- package/components/band/fixtures.json +8 -0
- package/components/band/meta.json +323 -0
- package/components/card-grid/CardGrid.astro +7 -3
- package/components/card-grid/fixtures.json +2 -1
- package/components/card-grid/meta.json +111 -0
- package/components/contact-details/ContactDetails.astro +7 -2
- package/components/contact-details/meta.json +113 -1
- package/components/contact-form/ContactForm.astro +48 -12
- package/components/contact-form/fixtures.json +169 -9
- package/components/contact-form/meta.json +283 -15
- package/components/cta/Cta.astro +52 -12
- package/components/cta/fixtures.json +5 -1
- package/components/cta/meta.json +11 -2
- package/components/faq/Faq.astro +60 -17
- package/components/faq/fixtures.json +75 -0
- package/components/faq/meta.json +8 -2
- package/components/feature-list/FeatureList.astro +34 -6
- package/components/feature-list/fixtures.json +2 -1
- package/components/feature-list/meta.json +125 -1
- package/components/gallery/Gallery.astro +47 -13
- package/components/gallery/fixtures.json +275 -0
- package/components/gallery/meta.json +266 -14
- package/components/hero/Hero.astro +14 -8
- package/components/hero/fixtures.json +2 -1
- package/components/hero/meta.json +111 -0
- package/components/logo-cloud/LogoCloud.astro +85 -21
- package/components/logo-cloud/fixtures.json +239 -0
- package/components/logo-cloud/meta.json +145 -5
- package/components/newsletter/Newsletter.astro +5 -1
- package/components/newsletter/meta.json +113 -1
- package/components/post-card/PostCard.astro +4 -2
- package/components/post-card/meta.json +2 -1
- package/components/post-list/PostList.astro +114 -0
- package/components/post-list/fixtures.json +387 -0
- package/components/post-list/meta.json +308 -0
- package/components/pricing/Pricing.astro +128 -24
- package/components/pricing/fixtures.json +288 -4
- package/components/pricing/meta.json +188 -1
- package/components/split/Split.astro +76 -14
- package/components/split/fixtures.json +8 -1
- package/components/split/meta.json +226 -4
- package/components/stats/Stats.astro +42 -10
- package/components/stats/fixtures.json +173 -0
- package/components/stats/meta.json +153 -5
- package/components/steps/Steps.astro +36 -8
- package/components/steps/fixtures.json +217 -5
- package/components/steps/meta.json +157 -3
- package/components/tabs/Tabs.astro +5 -2
- package/components/tabs/meta.json +2 -1
- package/components/team/Team.astro +52 -8
- package/components/team/fixtures.json +217 -4
- package/components/team/meta.json +168 -3
- package/components/team/team.ts +9 -0
- package/components/testimonial/Testimonial.astro +71 -21
- package/components/testimonial/fixtures.json +73 -0
- package/components/testimonial/meta.json +167 -3
- package/mapping/classic.json +7 -1
- package/mapping/divi.json +21 -4
- package/mapping/elementor.json +33 -8
- package/mapping/gutenberg.json +20 -4
- 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={
|
|
47
|
-
{
|
|
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,152 @@
|
|
|
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
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A list laid out in CSS columns (not a grid, so items of unequal height pack) at the measured columns and gap, or
|
|
146
|
+
* `undefined` to keep the component's own variant. Literal class names, read from the custom properties Section sets.
|
|
147
|
+
*/
|
|
148
|
+
export function measuredColumns(measured: MeasuredStyle | undefined): string | undefined {
|
|
149
|
+
if (!measured || !columnsFor(measured.columns)) return undefined
|
|
150
|
+
const gap = clamp(measured.gap, 0, 200) !== undefined ? 'gap-x-[var(--kit-gap)]' : ''
|
|
151
|
+
return ['block columns-[var(--kit-cols-390)] md:columns-[var(--kit-cols-768)] lg:columns-[var(--kit-cols-1280)]', gap].filter(Boolean).join(' ')
|
|
152
|
+
}
|
|
@@ -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
|
+
]
|