@contentrain/astro-kit 0.1.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 +89 -0
- package/catalog.json +1780 -0
- package/components/_shared/Button.astro +37 -0
- package/components/_shared/KitImage.astro +21 -0
- package/components/_shared/Section.astro +43 -0
- package/components/_shared/types.ts +26 -0
- package/components/breadcrumb/Breadcrumb.astro +56 -0
- package/components/breadcrumb/fixtures.json +5 -0
- package/components/breadcrumb/meta.json +27 -0
- package/components/card-grid/CardGrid.astro +82 -0
- package/components/card-grid/fixtures.json +6 -0
- package/components/card-grid/meta.json +123 -0
- package/components/contact-form/ContactForm.astro +79 -0
- package/components/contact-form/fixtures.json +18 -0
- package/components/contact-form/meta.json +28 -0
- package/components/cta/Cta.astro +64 -0
- package/components/cta/fixtures.json +6 -0
- package/components/cta/meta.json +28 -0
- package/components/faq/Faq.astro +79 -0
- package/components/faq/fixtures.json +36 -0
- package/components/faq/meta.json +30 -0
- package/components/footer/Footer.astro +87 -0
- package/components/footer/fixtures.json +5 -0
- package/components/footer/meta.json +31 -0
- package/components/gallery/Gallery.astro +88 -0
- package/components/gallery/fixtures.json +191 -0
- package/components/gallery/meta.json +30 -0
- package/components/header/Header.astro +62 -0
- package/components/header/fixtures.json +125 -0
- package/components/header/meta.json +34 -0
- package/components/hero/Hero.astro +84 -0
- package/components/hero/fixtures.json +95 -0
- package/components/hero/meta.json +127 -0
- package/components/nav/Nav.astro +102 -0
- package/components/nav/fixtures.json +80 -0
- package/components/nav/meta.json +30 -0
- package/components/pagination/Pagination.astro +69 -0
- package/components/pagination/fixtures.json +6 -0
- package/components/pagination/meta.json +31 -0
- package/components/post-card/PostCard.astro +67 -0
- package/components/post-card/fixtures.json +54 -0
- package/components/post-card/meta.json +32 -0
- package/components/prose/Prose.astro +30 -0
- package/components/prose/fixtures.json +4 -0
- package/components/prose/meta.json +69 -0
- package/components/slider/Slider.astro +136 -0
- package/components/slider/fixtures.json +5 -0
- package/components/slider/meta.json +32 -0
- package/components/tabs/Tabs.astro +101 -0
- package/components/tabs/fixtures.json +37 -0
- package/components/tabs/meta.json +28 -0
- package/components/testimonial/Testimonial.astro +70 -0
- package/components/testimonial/fixtures.json +26 -0
- package/components/testimonial/meta.json +97 -0
- package/dist/index.d.mts +150 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +199 -0
- package/dist/index.mjs.map +1 -0
- package/mapping/classic.json +261 -0
- package/mapping/divi.json +295 -0
- package/mapping/elementor.json +322 -0
- package/mapping/gutenberg.json +395 -0
- package/package.json +63 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A link drawn as a button — the kit's call-to-action primitive. Actions in
|
|
3
|
+
// migrated content are links, so this is always an <a>.
|
|
4
|
+
import { tv } from 'tailwind-variants'
|
|
5
|
+
import type { ActionInput } from './types'
|
|
6
|
+
|
|
7
|
+
const button = tv({
|
|
8
|
+
base: 'inline-flex items-center justify-center gap-2 rounded-md font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
9
|
+
variants: {
|
|
10
|
+
style: {
|
|
11
|
+
primary: 'bg-accent text-accent-ink hover:bg-ink hover:text-surface',
|
|
12
|
+
secondary: 'bg-ink text-surface hover:bg-accent hover:text-accent-ink',
|
|
13
|
+
ghost: 'border border-current bg-transparent text-current hover:bg-ink/5',
|
|
14
|
+
},
|
|
15
|
+
size: {
|
|
16
|
+
sm: 'px-3 py-1.5 text-sm',
|
|
17
|
+
md: 'px-5 py-2.5 text-base',
|
|
18
|
+
lg: 'px-6 py-3 text-lg',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: { style: 'primary', size: 'md' },
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
action: ActionInput
|
|
26
|
+
size?: 'sm' | 'md' | 'lg' | undefined
|
|
27
|
+
class?: string | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { action, size, class: className } = Astro.props
|
|
31
|
+
---
|
|
32
|
+
<a
|
|
33
|
+
href={action.href}
|
|
34
|
+
target={action.newTab ? '_blank' : undefined}
|
|
35
|
+
rel={action.newTab ? 'noopener' : undefined}
|
|
36
|
+
class={button({ style: action.style ?? 'primary', size, class: className })}
|
|
37
|
+
>{action.label}</a>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
// An image through astro:assets when its size is known — optimized and
|
|
3
|
+
// sized, so the layout never shifts — and a plain lazy <img> when it is not.
|
|
4
|
+
import { Image } from 'astro:assets'
|
|
5
|
+
import type { ImageInput } from './types'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
image: ImageInput
|
|
9
|
+
/** Rendered width in the layout, for srcset selection. */
|
|
10
|
+
sizes?: string | undefined
|
|
11
|
+
class?: string | undefined
|
|
12
|
+
/** The page's largest image: load it first. */
|
|
13
|
+
priority?: boolean | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { image, sizes = '100vw', class: className, priority = false } = Astro.props
|
|
17
|
+
const loading = priority ? 'eager' : 'lazy'
|
|
18
|
+
---
|
|
19
|
+
{image.width && image.height
|
|
20
|
+
? <Image src={image.src} alt={image.alt} width={image.width} height={image.height} sizes={sizes} class={className} loading={loading} fetchpriority={priority ? 'high' : 'auto'} />
|
|
21
|
+
: <img src={image.src} alt={image.alt} class={className} loading={loading} decoding="async" />}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A page section: background tone, vertical rhythm and the page frame. Every
|
|
3
|
+
// section component renders inside one, so sections stack evenly whatever
|
|
4
|
+
// their order.
|
|
5
|
+
import { tv } from 'tailwind-variants'
|
|
6
|
+
|
|
7
|
+
const section = tv({
|
|
8
|
+
base: '',
|
|
9
|
+
variants: {
|
|
10
|
+
tone: {
|
|
11
|
+
default: 'bg-surface text-ink',
|
|
12
|
+
muted: 'bg-surface-muted text-ink',
|
|
13
|
+
inverse: 'bg-ink text-surface',
|
|
14
|
+
accent: 'bg-accent text-accent-ink',
|
|
15
|
+
},
|
|
16
|
+
spacing: {
|
|
17
|
+
none: '',
|
|
18
|
+
sm: 'py-8 md:py-12',
|
|
19
|
+
md: 'py-12 md:py-20',
|
|
20
|
+
lg: 'py-16 md:py-28',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: { tone: 'default', spacing: 'md' },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export type Tone = 'default' | 'muted' | 'inverse' | 'accent'
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
tone?: Tone | undefined
|
|
30
|
+
spacing?: 'none' | 'sm' | 'md' | 'lg' | undefined
|
|
31
|
+
/** `page` is the full frame, `prose` the reading column, `none` for full-bleed content. */
|
|
32
|
+
width?: 'page' | 'prose' | 'none' | undefined
|
|
33
|
+
as?: 'section' | 'div' | 'aside' | undefined
|
|
34
|
+
id?: string | undefined
|
|
35
|
+
class?: string | undefined
|
|
36
|
+
'aria-labelledby'?: string | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { tone, spacing, width = 'page', as: Tag = 'section', id, class: className, 'aria-labelledby': labelledBy } = Astro.props
|
|
40
|
+
---
|
|
41
|
+
<Tag id={id} aria-labelledby={labelledBy} class={section({ tone, spacing, class: className })} data-tone={tone ?? 'default'}>
|
|
42
|
+
{width === 'none' ? <slot /> : <div class={width === 'prose' ? 'container-prose' : 'container-page'}><slot /></div>}
|
|
43
|
+
</Tag>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Value shapes the kit components share. Plain data — what a Contentrain
|
|
2
|
+
// entry resolves to — so a page passes content straight through.
|
|
3
|
+
|
|
4
|
+
/** An image with the text that replaces it. Dimensions let astro:assets size it. */
|
|
5
|
+
export interface ImageInput {
|
|
6
|
+
src: string
|
|
7
|
+
alt: string
|
|
8
|
+
width?: number | undefined
|
|
9
|
+
height?: number | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LinkInput {
|
|
13
|
+
label: string
|
|
14
|
+
href: string
|
|
15
|
+
newTab?: boolean | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A call to action: a link drawn as a button. */
|
|
19
|
+
export interface ActionInput extends LinkInput {
|
|
20
|
+
style?: 'primary' | 'secondary' | 'ghost' | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A menu entry, possibly with children (one level shown in the kit's navigation). */
|
|
24
|
+
export interface NavItem extends LinkInput {
|
|
25
|
+
children?: NavItem[] | undefined
|
|
26
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Where the page sits: its ancestors as links, then the page itself. Can emit
|
|
3
|
+
// the matching BreadcrumbList JSON-LD.
|
|
4
|
+
import { tv } from 'tailwind-variants'
|
|
5
|
+
import type { LinkInput } from '../_shared/types'
|
|
6
|
+
|
|
7
|
+
const breadcrumb = tv({
|
|
8
|
+
slots: {
|
|
9
|
+
list: 'flex flex-wrap items-center gap-2 text-sm text-ink-muted',
|
|
10
|
+
link: 'text-ink-muted no-underline hover:text-ink hover:underline',
|
|
11
|
+
},
|
|
12
|
+
variants: {
|
|
13
|
+
separator: { slash: {}, chevron: {}, dot: {} },
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: { separator: 'slash' },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const MARKS = { slash: '/', chevron: '›', dot: '·' } as const
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** Ancestors, root first. */
|
|
22
|
+
items: LinkInput[]
|
|
23
|
+
/** The current page's title. */
|
|
24
|
+
current: string
|
|
25
|
+
/** Accessible name ("Breadcrumb"). */
|
|
26
|
+
label?: string | undefined
|
|
27
|
+
/** Emit schema.org BreadcrumbList JSON-LD. Leave off when the page's SEO graph already has one. */
|
|
28
|
+
schema?: boolean | undefined
|
|
29
|
+
separator?: 'slash' | 'chevron' | 'dot' | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { items, current, label = 'Breadcrumb', schema = false, separator = 'slash' } = Astro.props
|
|
33
|
+
const slots = breadcrumb({ separator })
|
|
34
|
+
const origin = Astro.site ?? Astro.url
|
|
35
|
+
const jsonLd = schema
|
|
36
|
+
? JSON.stringify({
|
|
37
|
+
'@context': 'https://schema.org',
|
|
38
|
+
'@type': 'BreadcrumbList',
|
|
39
|
+
itemListElement: [...items, { label: current, href: Astro.url.pathname }].map((item, index) => ({
|
|
40
|
+
'@type': 'ListItem', position: index + 1, name: item.label, item: new URL(item.href, origin).href,
|
|
41
|
+
})),
|
|
42
|
+
}).replace(/</g, '\\u003c')
|
|
43
|
+
: undefined
|
|
44
|
+
---
|
|
45
|
+
<nav aria-label={label}>
|
|
46
|
+
<ol class={slots.list()}>
|
|
47
|
+
{items.map(item => (
|
|
48
|
+
<li class="flex items-center gap-2">
|
|
49
|
+
<a href={item.href} class={slots.link()}>{item.label}</a>
|
|
50
|
+
<span aria-hidden="true">{MARKS[separator]}</span>
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
<li aria-current="page" class="text-ink">{current}</li>
|
|
54
|
+
</ol>
|
|
55
|
+
</nav>
|
|
56
|
+
{jsonLd && <script type="application/ld+json" is:inline set:html={jsonLd} />}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "name": "slash", "props": { "items": [{ "label": "Home", "href": "/" }, { "label": "About", "href": "/about/" }], "current": "Team" } },
|
|
3
|
+
{ "name": "chevron", "props": { "separator": "chevron", "items": [{ "label": "Home", "href": "/" }, { "label": "Guides", "href": "/category/guides/" }], "current": "What a careful migration keeps" } },
|
|
4
|
+
{ "name": "dot-schema", "props": { "separator": "dot", "schema": true, "items": [{ "label": "Home", "href": "/" }], "current": "Contact" } }
|
|
5
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "breadcrumb",
|
|
3
|
+
"name": "Breadcrumb",
|
|
4
|
+
"description": "The page's ancestors as links followed by the page itself; optionally emits BreadcrumbList JSON-LD.",
|
|
5
|
+
"category": "content",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": ["types.ts"],
|
|
8
|
+
"variants": {
|
|
9
|
+
"separator": { "options": ["slash", "chevron", "dot"], "default": "slash", "description": "Mark between items." }
|
|
10
|
+
},
|
|
11
|
+
"props": {
|
|
12
|
+
"items": { "type": "array", "required": true, "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true } } }, "description": "Ancestors, root first (pages.parent chain, or Home → category)." },
|
|
13
|
+
"current": { "type": "string", "required": true, "description": "Current page title." },
|
|
14
|
+
"label": { "type": "string", "description": "Accessible name (ui-strings nav.breadcrumb)." },
|
|
15
|
+
"schema": { "type": "boolean", "default": false, "description": "Emit BreadcrumbList JSON-LD (off when the SEO graph has one)." }
|
|
16
|
+
},
|
|
17
|
+
"slots": {},
|
|
18
|
+
"js": "none",
|
|
19
|
+
"dependencies": {},
|
|
20
|
+
"sources": {
|
|
21
|
+
"gutenberg": ["yoast-seo/breadcrumbs", "rank-math/breadcrumb"],
|
|
22
|
+
"elementor": ["elementor/breadcrumbs"],
|
|
23
|
+
"divi": ["divi/et_pb_breadcrumbs"],
|
|
24
|
+
"classic": ["classic/yoast-breadcrumb", "classic/rank-math-breadcrumb"]
|
|
25
|
+
},
|
|
26
|
+
"a11y": "A labelled <nav> with an ordered list; the current page is aria-current=page and not a link; separators are aria-hidden."
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A grid of cards — features, services, team members, linked pages — with an
|
|
3
|
+
// optional heading and intro above it.
|
|
4
|
+
import { tv } from 'tailwind-variants'
|
|
5
|
+
import KitImage from '../_shared/KitImage.astro'
|
|
6
|
+
import Section, { type Tone } from '../_shared/Section.astro'
|
|
7
|
+
import type { ImageInput } from '../_shared/types'
|
|
8
|
+
|
|
9
|
+
const grid = tv({
|
|
10
|
+
slots: {
|
|
11
|
+
list: 'grid items-start gap-6 md:gap-8',
|
|
12
|
+
card: 'group relative flex flex-col gap-3 rounded-[var(--radius-card)]',
|
|
13
|
+
media: 'overflow-hidden rounded-[var(--radius-card)]',
|
|
14
|
+
title: 'text-lg leading-snug font-semibold text-balance',
|
|
15
|
+
text: 'opacity-80',
|
|
16
|
+
},
|
|
17
|
+
variants: {
|
|
18
|
+
columns: {
|
|
19
|
+
'2': { list: 'sm:grid-cols-2' },
|
|
20
|
+
'3': { list: 'sm:grid-cols-2 md:grid-cols-3' },
|
|
21
|
+
'4': { list: 'sm:grid-cols-2 lg:grid-cols-4' },
|
|
22
|
+
},
|
|
23
|
+
style: {
|
|
24
|
+
plain: {},
|
|
25
|
+
bordered: { card: 'border border-line p-6', media: '-mx-6 -mt-6 mb-2 rounded-b-none' },
|
|
26
|
+
elevated: { card: 'bg-surface p-6 text-ink shadow-md ring-1 ring-line/60', media: '-mx-6 -mt-6 mb-2 rounded-b-none' },
|
|
27
|
+
},
|
|
28
|
+
align: { start: { card: 'text-left' }, center: { card: 'items-center text-center' } },
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: { columns: '3', style: 'plain', align: 'start' },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
interface Item {
|
|
34
|
+
title: string
|
|
35
|
+
text?: string | undefined
|
|
36
|
+
href?: string | undefined
|
|
37
|
+
image?: ImageInput | undefined
|
|
38
|
+
eyebrow?: string | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Props {
|
|
42
|
+
title?: string | undefined
|
|
43
|
+
intro?: string | undefined
|
|
44
|
+
items: Item[]
|
|
45
|
+
/** Heading level of the section title; card titles are one level below. */
|
|
46
|
+
headingLevel?: 2 | 3 | undefined
|
|
47
|
+
columns?: '2' | '3' | '4' | undefined
|
|
48
|
+
style?: 'plain' | 'bordered' | 'elevated' | undefined
|
|
49
|
+
align?: 'start' | 'center' | undefined
|
|
50
|
+
tone?: Tone | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { title, intro, items, headingLevel = 2, columns, style, align, tone } = Astro.props
|
|
54
|
+
const { list, card, media, title: cardTitle, text } = grid({ columns, style, align })
|
|
55
|
+
// Accent backgrounds have the least contrast headroom: text on them is never dimmed.
|
|
56
|
+
const dim = tone === 'accent' ? 'opacity-100' : undefined
|
|
57
|
+
const Heading = `h${headingLevel}` as 'h2' | 'h3'
|
|
58
|
+
const CardHeading = `h${headingLevel + 1}` as 'h3' | 'h4'
|
|
59
|
+
const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : undefined
|
|
60
|
+
---
|
|
61
|
+
<Section tone={tone} aria-labelledby={headingId}>
|
|
62
|
+
{(title || intro) && (
|
|
63
|
+
<header class:list={['mb-10 flex max-w-2xl flex-col gap-3', align === 'center' && 'mx-auto items-center text-center']}>
|
|
64
|
+
{title && <Heading id={headingId} class="text-3xl font-bold tracking-tight text-balance md:text-4xl">{title}</Heading>}
|
|
65
|
+
{intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']}>{intro}</p>}
|
|
66
|
+
</header>
|
|
67
|
+
)}
|
|
68
|
+
<ul class={list()}>
|
|
69
|
+
{items.map(item => (
|
|
70
|
+
<li class={card()}>
|
|
71
|
+
{item.image && <div class={media()}><KitImage image={item.image} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
|
|
72
|
+
{item.eyebrow && <p class="text-sm font-medium text-accent">{item.eyebrow}</p>}
|
|
73
|
+
<CardHeading class={cardTitle()}>
|
|
74
|
+
{item.href
|
|
75
|
+
? <a href={item.href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent">{item.title}</a>
|
|
76
|
+
: item.title}
|
|
77
|
+
</CardHeading>
|
|
78
|
+
{item.text && <p class={text({ class: dim })}>{item.text}</p>}
|
|
79
|
+
</li>
|
|
80
|
+
))}
|
|
81
|
+
</ul>
|
|
82
|
+
</Section>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "name": "three-plain", "props": { "title": "What we do", "intro": "Three things, done carefully.", "items": [{ "title": "Design systems", "text": "Tokens, components and documentation your team keeps using.", "href": "/services/design/", "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800 } }, { "title": "Migrations", "text": "WordPress to Astro without losing an address or a ranking.", "href": "/services/migrations/", "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "", "width": 1200, "height": 800 } }, { "title": "Performance", "text": "Measured, budgeted and kept fast after launch.", "href": "/services/performance/", "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "", "width": 1200, "height": 800 } }] } },
|
|
3
|
+
{ "name": "two-bordered-center", "props": { "columns": "2", "style": "bordered", "align": "center", "tone": "muted", "title": "Plans", "items": [{ "eyebrow": "Starter", "title": "One site", "text": "Everything you need for a single site." }, { "eyebrow": "Team", "title": "Many sites", "text": "Shared components across a portfolio." }] } },
|
|
4
|
+
{ "name": "four-elevated-inverse", "props": { "columns": "4", "style": "elevated", "tone": "inverse", "headingLevel": 3, "title": "Team", "items": [{ "title": "Mira Kovač", "text": "Editor", "image": { "src": "/kit-fixtures/avatar-1.svg", "alt": "Mira Kovač", "width": 96, "height": 96 } }, { "title": "Jon Ade", "text": "Engineer", "image": { "src": "/kit-fixtures/avatar-2.svg", "alt": "Jon Ade", "width": 96, "height": 96 } }, { "title": "Lea Park", "text": "Designer", "image": { "src": "/kit-fixtures/avatar-3.svg", "alt": "Lea Park", "width": 96, "height": 96 } }, { "title": "Sam Ortiz", "text": "Producer" }] } },
|
|
5
|
+
{ "name": "accent", "props": { "tone": "accent", "columns": "3", "items": [{ "title": "Fast", "text": "Static HTML, one stylesheet." }, { "title": "Editable", "text": "Content in Contentrain Studio." }, { "title": "Kept", "text": "Every address survives." }] } }
|
|
6
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "card-grid",
|
|
3
|
+
"name": "Card grid",
|
|
4
|
+
"description": "A grid of cards (features, services, team, linked pages) with an optional section heading and intro. A card with a link is clickable as a whole.",
|
|
5
|
+
"category": "section",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": [
|
|
8
|
+
"KitImage.astro",
|
|
9
|
+
"Section.astro",
|
|
10
|
+
"types.ts"
|
|
11
|
+
],
|
|
12
|
+
"variants": {
|
|
13
|
+
"columns": {
|
|
14
|
+
"options": [
|
|
15
|
+
"2",
|
|
16
|
+
"3",
|
|
17
|
+
"4"
|
|
18
|
+
],
|
|
19
|
+
"default": "3",
|
|
20
|
+
"description": "Columns on wide screens; one on phones, two on tablets."
|
|
21
|
+
},
|
|
22
|
+
"style": {
|
|
23
|
+
"options": [
|
|
24
|
+
"plain",
|
|
25
|
+
"bordered",
|
|
26
|
+
"elevated"
|
|
27
|
+
],
|
|
28
|
+
"default": "plain",
|
|
29
|
+
"description": "Card chrome: none, a border, or a raised surface."
|
|
30
|
+
},
|
|
31
|
+
"align": {
|
|
32
|
+
"options": [
|
|
33
|
+
"start",
|
|
34
|
+
"center"
|
|
35
|
+
],
|
|
36
|
+
"default": "start",
|
|
37
|
+
"description": "Text alignment inside cards and of the header."
|
|
38
|
+
},
|
|
39
|
+
"tone": {
|
|
40
|
+
"options": [
|
|
41
|
+
"default",
|
|
42
|
+
"muted",
|
|
43
|
+
"inverse",
|
|
44
|
+
"accent"
|
|
45
|
+
],
|
|
46
|
+
"default": "default",
|
|
47
|
+
"description": "Section background."
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"props": {
|
|
51
|
+
"title": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Section heading."
|
|
54
|
+
},
|
|
55
|
+
"intro": {
|
|
56
|
+
"type": "text",
|
|
57
|
+
"description": "Paragraph under the heading."
|
|
58
|
+
},
|
|
59
|
+
"items": {
|
|
60
|
+
"type": "array",
|
|
61
|
+
"required": true,
|
|
62
|
+
"items": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"fields": {
|
|
65
|
+
"title": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"required": true
|
|
68
|
+
},
|
|
69
|
+
"text": {
|
|
70
|
+
"type": "text"
|
|
71
|
+
},
|
|
72
|
+
"href": {
|
|
73
|
+
"type": "url"
|
|
74
|
+
},
|
|
75
|
+
"image": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"fields": {
|
|
78
|
+
"src": { "type": "image", "required": true },
|
|
79
|
+
"alt": { "type": "string", "required": true },
|
|
80
|
+
"width": { "type": "integer" },
|
|
81
|
+
"height": { "type": "integer" }
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"eyebrow": {
|
|
85
|
+
"type": "string"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"description": "The cards. image is an ImageInput ({ src, alt, width?, height? })."
|
|
90
|
+
},
|
|
91
|
+
"headingLevel": {
|
|
92
|
+
"type": "integer",
|
|
93
|
+
"min": 2,
|
|
94
|
+
"max": 3,
|
|
95
|
+
"default": 2,
|
|
96
|
+
"description": "Level of the section heading; card titles are one below."
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"slots": {},
|
|
100
|
+
"js": "none",
|
|
101
|
+
"dependencies": {},
|
|
102
|
+
"sources": {
|
|
103
|
+
"gutenberg": [
|
|
104
|
+
"core/columns",
|
|
105
|
+
"core/media-text"
|
|
106
|
+
],
|
|
107
|
+
"elementor": [
|
|
108
|
+
"elementor/icon-box",
|
|
109
|
+
"elementor/image-box",
|
|
110
|
+
"elementor/call-to-action",
|
|
111
|
+
"elementor/posts"
|
|
112
|
+
],
|
|
113
|
+
"divi": [
|
|
114
|
+
"divi/et_pb_blurb",
|
|
115
|
+
"divi/et_pb_team_member",
|
|
116
|
+
"divi/et_pb_pricing_tables"
|
|
117
|
+
],
|
|
118
|
+
"classic": [
|
|
119
|
+
"classic/widget-area"
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
"a11y": "A list of cards; the whole card is clickable through a stretched link on its title, so screen readers hear one link per card. The section is labelled by its heading."
|
|
123
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The contact section: heading, a line of introduction, the ways to reach you
|
|
3
|
+
// and the form. The form is a Contentrain Studio form (the starter's
|
|
4
|
+
// StudioForm): its fields, spam protection and notifications live in Studio,
|
|
5
|
+
// and submissions arrive there for review — no form plugin, no mail server.
|
|
6
|
+
// Until the site is bound to a Studio project the form renders nothing and the
|
|
7
|
+
// contact details still show.
|
|
8
|
+
import { tv } from 'tailwind-variants'
|
|
9
|
+
import Section from '../_shared/Section.astro'
|
|
10
|
+
import StudioForm from '../../studio/StudioForm.astro'
|
|
11
|
+
import { siteConfig } from '../../../site.config'
|
|
12
|
+
|
|
13
|
+
export interface ContactDetail {
|
|
14
|
+
label: string
|
|
15
|
+
value: string
|
|
16
|
+
/** `mailto:`, `tel:` or a map link. */
|
|
17
|
+
href?: string | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const contact = tv({
|
|
21
|
+
slots: {
|
|
22
|
+
grid: 'grid gap-10',
|
|
23
|
+
copy: 'flex flex-col gap-4',
|
|
24
|
+
heading: 'text-2xl font-bold tracking-tight text-balance md:text-3xl',
|
|
25
|
+
intro: 'max-w-xl text-pretty opacity-85',
|
|
26
|
+
details: 'mt-2 flex flex-col gap-4',
|
|
27
|
+
term: 'text-sm font-semibold opacity-75',
|
|
28
|
+
value: 'text-current',
|
|
29
|
+
form: 'rounded-card',
|
|
30
|
+
},
|
|
31
|
+
variants: {
|
|
32
|
+
layout: {
|
|
33
|
+
split: { grid: 'md:grid-cols-[2fr_3fr] md:items-start' },
|
|
34
|
+
stacked: { grid: 'mx-auto max-w-2xl' },
|
|
35
|
+
},
|
|
36
|
+
// accent/accent-ink is only ~5:1 — dimming text on it drops body copy under 4.5:1 (WCAG 1.4.3), so accent keeps it at full strength.
|
|
37
|
+
tone: { default: { form: 'border border-line bg-surface p-6 md:p-8' }, muted: { form: 'bg-surface p-6 text-ink md:p-8' }, inverse: { form: 'bg-surface p-6 text-ink md:p-8' }, accent: { form: 'bg-surface p-6 text-ink md:p-8', intro: 'opacity-100', term: 'opacity-100' } },
|
|
38
|
+
},
|
|
39
|
+
defaultVariants: { layout: 'split', tone: 'default' },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
interface Props {
|
|
43
|
+
/** The Studio model submissions are saved to (e.g. `contact-submissions`). */
|
|
44
|
+
model: string
|
|
45
|
+
heading?: string | undefined
|
|
46
|
+
intro?: string | undefined
|
|
47
|
+
details?: ContactDetail[] | undefined
|
|
48
|
+
layout?: 'split' | 'stacked' | undefined
|
|
49
|
+
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
50
|
+
id?: string | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { model, heading, intro, details = [], layout, tone, id = 'contact' } = Astro.props
|
|
54
|
+
// No Studio project bound: no form renders, so a split would leave an empty column — the details stand alone.
|
|
55
|
+
const formShown = Boolean(siteConfig.studio)
|
|
56
|
+
const s = contact({ layout: formShown ? layout : 'stacked', tone })
|
|
57
|
+
const headingId = heading ? `${id}-title` : undefined
|
|
58
|
+
---
|
|
59
|
+
<Section tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
|
|
60
|
+
<div class={s.grid()}>
|
|
61
|
+
{(heading || intro || details.length > 0) && (
|
|
62
|
+
<div class={s.copy()}>
|
|
63
|
+
{heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
|
|
64
|
+
{intro && <p class={s.intro()}>{intro}</p>}
|
|
65
|
+
{details.length > 0 && (
|
|
66
|
+
<dl class={s.details()}>
|
|
67
|
+
{details.map(detail => (
|
|
68
|
+
<div>
|
|
69
|
+
<dt class={s.term()}>{detail.label}</dt>
|
|
70
|
+
<dd class={s.value()}>{detail.href ? <a href={detail.href} class="underline decoration-line underline-offset-4 hover:decoration-current">{detail.value}</a> : detail.value}</dd>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</dl>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
{formShown && <StudioForm model={model} class={s.form()} />}
|
|
78
|
+
</div>
|
|
79
|
+
</Section>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "split",
|
|
4
|
+
"props": {
|
|
5
|
+
"model": "contact-submissions",
|
|
6
|
+
"heading": "Get in touch",
|
|
7
|
+
"intro": "Questions, corrections and invitations are all welcome. We answer within two working days.",
|
|
8
|
+
"details": [
|
|
9
|
+
{ "label": "Email", "value": "hello@northwind.example", "href": "mailto:hello@northwind.example" },
|
|
10
|
+
{ "label": "Phone", "value": "+351 21 000 0000", "href": "tel:+351210000000" },
|
|
11
|
+
{ "label": "Studio", "value": "Rua das Flores 12, Lisbon" }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
{ "name": "stacked-muted", "props": { "model": "contact-submissions", "heading": "Write to us", "layout": "stacked", "tone": "muted", "id": "write" } },
|
|
16
|
+
{ "name": "split-inverse", "props": { "model": "contact-submissions", "heading": "Press", "tone": "inverse", "id": "press", "details": [{ "label": "Press office", "value": "press@northwind.example", "href": "mailto:press@northwind.example" }] } },
|
|
17
|
+
{ "name": "stacked-accent", "props": { "model": "newsletter-requests", "heading": "Request a print copy", "layout": "stacked", "tone": "accent", "id": "print" } }
|
|
18
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "contact-form",
|
|
3
|
+
"name": "Contact form",
|
|
4
|
+
"description": "The contact section: heading, introduction, contact details (email, phone, address) and a Contentrain Studio form — fields, spam protection and notifications set in Studio, submissions reviewed there. Replaces form plugins; renders the details alone until the site is bound to a Studio project.",
|
|
5
|
+
"category": "section",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": ["Section.astro"],
|
|
8
|
+
"variants": {
|
|
9
|
+
"layout": { "options": ["split", "stacked"], "default": "split", "description": "split: details beside the form on wide screens. stacked: one centred column." },
|
|
10
|
+
"tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background; the form always sits on a surface card." }
|
|
11
|
+
},
|
|
12
|
+
"props": {
|
|
13
|
+
"model": { "type": "string", "required": true, "description": "The Studio model submissions are saved to — the form's fields are that model's form settings (from the plugin form's fields at migration)." },
|
|
14
|
+
"heading": { "type": "string", "description": "Section heading." },
|
|
15
|
+
"intro": { "type": "text", "description": "A line or two above the details." },
|
|
16
|
+
"details": { "type": "array", "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "value": { "type": "string", "required": true }, "href": { "type": "url" } } }, "description": "Ways to reach you, in order (Email → mailto:, Phone → tel:, Address → map link)." },
|
|
17
|
+
"id": { "type": "string", "description": "Section id; the heading gets `<id>-title`." }
|
|
18
|
+
},
|
|
19
|
+
"slots": {},
|
|
20
|
+
"js": "studio",
|
|
21
|
+
"dependencies": {},
|
|
22
|
+
"sources": {
|
|
23
|
+
"elementor": ["elementor/form"],
|
|
24
|
+
"divi": ["divi/et_pb_contact_form"],
|
|
25
|
+
"classic": ["classic/contact-form-7", "classic/wpforms", "classic/gravityforms", "classic/ninja-forms"]
|
|
26
|
+
},
|
|
27
|
+
"a11y": "Contact details are a description list; links say what they open. The form's labels, errors and focus handling are Studio's form runtime (FormMount). The section is labelled by its heading."
|
|
28
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A call to action: a short heading, a line of text and one or two buttons —
|
|
3
|
+
// across the full section, or as a card inside it.
|
|
4
|
+
import { tv } from 'tailwind-variants'
|
|
5
|
+
import Button from '../_shared/Button.astro'
|
|
6
|
+
import Section from '../_shared/Section.astro'
|
|
7
|
+
import type { ActionInput } from '../_shared/types'
|
|
8
|
+
|
|
9
|
+
const cta = tv({
|
|
10
|
+
slots: {
|
|
11
|
+
box: 'flex flex-col gap-6',
|
|
12
|
+
copy: 'flex flex-col gap-3',
|
|
13
|
+
heading: 'text-2xl font-bold tracking-tight text-balance md:text-3xl',
|
|
14
|
+
body: 'max-w-2xl text-pretty opacity-85',
|
|
15
|
+
actions: 'flex flex-wrap gap-3',
|
|
16
|
+
},
|
|
17
|
+
variants: {
|
|
18
|
+
layout: {
|
|
19
|
+
inline: { box: 'md:flex-row md:items-center md:justify-between', actions: 'md:shrink-0' },
|
|
20
|
+
centered: { box: 'items-center text-center', body: 'mx-auto', actions: 'justify-center' },
|
|
21
|
+
},
|
|
22
|
+
frame: {
|
|
23
|
+
full: {},
|
|
24
|
+
card: { box: 'rounded-card border border-line p-8 md:p-12' },
|
|
25
|
+
},
|
|
26
|
+
// accent/accent-ink is only ~5:1 — dimming text on it drops body copy under 4.5:1 (WCAG 1.4.3), so accent keeps it at full strength.
|
|
27
|
+
tone: { default: {}, muted: {}, inverse: {}, accent: { body: 'opacity-100' } },
|
|
28
|
+
},
|
|
29
|
+
compoundVariants: [
|
|
30
|
+
// A card carries the tone itself, on a plain section; a plain card still needs a surface to read as one.
|
|
31
|
+
{ frame: 'card', tone: 'default', class: { box: 'bg-surface-muted text-ink' } },
|
|
32
|
+
{ frame: 'card', tone: 'muted', class: { box: 'border-transparent bg-surface-muted text-ink' } },
|
|
33
|
+
{ frame: 'card', tone: 'inverse', class: { box: 'border-transparent bg-ink text-surface' } },
|
|
34
|
+
{ frame: 'card', tone: 'accent', class: { box: 'border-transparent bg-accent text-accent-ink' } },
|
|
35
|
+
],
|
|
36
|
+
defaultVariants: { layout: 'inline', frame: 'full', tone: 'accent' },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
heading: string
|
|
41
|
+
body?: string | undefined
|
|
42
|
+
actions: ActionInput[]
|
|
43
|
+
layout?: 'inline' | 'centered' | undefined
|
|
44
|
+
frame?: 'full' | 'card' | undefined
|
|
45
|
+
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
46
|
+
id?: string | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { heading, body, actions, layout, frame, tone = 'accent', id } = Astro.props
|
|
50
|
+
const s = cta({ layout, frame, tone })
|
|
51
|
+
const headingId = id ? `${id}-title` : undefined
|
|
52
|
+
// On the accent background the primary button (accent) would vanish: it is drawn as the secondary style there.
|
|
53
|
+
const onAccent = (action: ActionInput): ActionInput => ({ label: action.label, href: action.href, newTab: action.newTab, style: 'secondary' })
|
|
54
|
+
const shown = actions.map(action => (tone === 'accent' && (action.style ?? 'primary') === 'primary' ? onAccent(action) : action))
|
|
55
|
+
---
|
|
56
|
+
<Section tone={frame === 'card' ? 'default' : tone} spacing="md" id={id} aria-labelledby={headingId}>
|
|
57
|
+
<div class={s.box()}>
|
|
58
|
+
<div class={s.copy()}>
|
|
59
|
+
<h2 id={headingId} class={s.heading()}>{heading}</h2>
|
|
60
|
+
{body && <p class={s.body()}>{body}</p>}
|
|
61
|
+
</div>
|
|
62
|
+
{shown.length > 0 && <div class={s.actions()}>{shown.map(action => <Button action={action} size="lg" />)}</div>}
|
|
63
|
+
</div>
|
|
64
|
+
</Section>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "name": "inline-accent", "props": { "heading": "Get the next issue in your inbox", "body": "One email a month. No tracking, unsubscribe any time.", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }] } },
|
|
3
|
+
{ "name": "centered-inverse", "props": { "heading": "Planning a trip?", "body": "Start with the guides readers use most.", "layout": "centered", "tone": "inverse", "actions": [{ "label": "Popular guides", "href": "/guides/" }, { "label": "Ask a question", "href": "/contact/", "style": "ghost" }] } },
|
|
4
|
+
{ "name": "card-default", "props": { "heading": "Write for the journal", "body": "We publish reader essays every season.", "frame": "card", "tone": "default", "actions": [{ "label": "Pitch an essay", "href": "/pitch/" }] } },
|
|
5
|
+
{ "name": "card-muted", "props": { "heading": "Support independent travel writing", "frame": "card", "tone": "muted", "layout": "centered", "actions": [{ "label": "Become a member", "href": "/members/" }] } }
|
|
6
|
+
]
|