@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.
Files changed (63) hide show
  1. package/README.md +89 -0
  2. package/catalog.json +1780 -0
  3. package/components/_shared/Button.astro +37 -0
  4. package/components/_shared/KitImage.astro +21 -0
  5. package/components/_shared/Section.astro +43 -0
  6. package/components/_shared/types.ts +26 -0
  7. package/components/breadcrumb/Breadcrumb.astro +56 -0
  8. package/components/breadcrumb/fixtures.json +5 -0
  9. package/components/breadcrumb/meta.json +27 -0
  10. package/components/card-grid/CardGrid.astro +82 -0
  11. package/components/card-grid/fixtures.json +6 -0
  12. package/components/card-grid/meta.json +123 -0
  13. package/components/contact-form/ContactForm.astro +79 -0
  14. package/components/contact-form/fixtures.json +18 -0
  15. package/components/contact-form/meta.json +28 -0
  16. package/components/cta/Cta.astro +64 -0
  17. package/components/cta/fixtures.json +6 -0
  18. package/components/cta/meta.json +28 -0
  19. package/components/faq/Faq.astro +79 -0
  20. package/components/faq/fixtures.json +36 -0
  21. package/components/faq/meta.json +30 -0
  22. package/components/footer/Footer.astro +87 -0
  23. package/components/footer/fixtures.json +5 -0
  24. package/components/footer/meta.json +31 -0
  25. package/components/gallery/Gallery.astro +88 -0
  26. package/components/gallery/fixtures.json +191 -0
  27. package/components/gallery/meta.json +30 -0
  28. package/components/header/Header.astro +62 -0
  29. package/components/header/fixtures.json +125 -0
  30. package/components/header/meta.json +34 -0
  31. package/components/hero/Hero.astro +84 -0
  32. package/components/hero/fixtures.json +95 -0
  33. package/components/hero/meta.json +127 -0
  34. package/components/nav/Nav.astro +102 -0
  35. package/components/nav/fixtures.json +80 -0
  36. package/components/nav/meta.json +30 -0
  37. package/components/pagination/Pagination.astro +69 -0
  38. package/components/pagination/fixtures.json +6 -0
  39. package/components/pagination/meta.json +31 -0
  40. package/components/post-card/PostCard.astro +67 -0
  41. package/components/post-card/fixtures.json +54 -0
  42. package/components/post-card/meta.json +32 -0
  43. package/components/prose/Prose.astro +30 -0
  44. package/components/prose/fixtures.json +4 -0
  45. package/components/prose/meta.json +69 -0
  46. package/components/slider/Slider.astro +136 -0
  47. package/components/slider/fixtures.json +5 -0
  48. package/components/slider/meta.json +32 -0
  49. package/components/tabs/Tabs.astro +101 -0
  50. package/components/tabs/fixtures.json +37 -0
  51. package/components/tabs/meta.json +28 -0
  52. package/components/testimonial/Testimonial.astro +70 -0
  53. package/components/testimonial/fixtures.json +26 -0
  54. package/components/testimonial/meta.json +97 -0
  55. package/dist/index.d.mts +150 -0
  56. package/dist/index.d.mts.map +1 -0
  57. package/dist/index.mjs +199 -0
  58. package/dist/index.mjs.map +1 -0
  59. package/mapping/classic.json +261 -0
  60. package/mapping/divi.json +295 -0
  61. package/mapping/elementor.json +322 -0
  62. package/mapping/gutenberg.json +395 -0
  63. 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
+ ]