@contentrain/astro-kit 0.1.1 → 0.3.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 (43) hide show
  1. package/README.md +30 -3
  2. package/catalog.json +251 -4
  3. package/components/_shared/Button.astro +1 -1
  4. package/components/_shared/KitImage.astro +32 -5
  5. package/components/_shared/Section.astro +1 -1
  6. package/components/breadcrumb/Breadcrumb.astro +1 -1
  7. package/components/card-grid/CardGrid.astro +4 -4
  8. package/components/contact-form/ContactForm.astro +1 -1
  9. package/components/cta/Cta.astro +2 -2
  10. package/components/embed/Embed.astro +104 -0
  11. package/components/embed/embed.ts +106 -0
  12. package/components/embed/fixtures.json +34 -0
  13. package/components/embed/meta.json +32 -0
  14. package/components/faq/Faq.astro +5 -3
  15. package/components/faq/fixtures.json +4 -0
  16. package/components/faq/meta.json +1 -1
  17. package/components/footer/Footer.astro +4 -4
  18. package/components/footer/meta.json +107 -14
  19. package/components/gallery/Gallery.astro +3 -3
  20. package/components/header/Header.astro +1 -1
  21. package/components/hero/Hero.astro +14 -7
  22. package/components/hero/fixtures.json +21 -0
  23. package/components/hero/meta.json +8 -0
  24. package/components/nav/Nav.astro +4 -2
  25. package/components/nav/meta.json +86 -13
  26. package/components/newsletter/Newsletter.astro +110 -0
  27. package/components/newsletter/fixtures.json +6 -0
  28. package/components/newsletter/meta.json +105 -0
  29. package/components/newsletter/newsletter.ts +73 -0
  30. package/components/pagination/Pagination.astro +1 -1
  31. package/components/post-card/PostCard.astro +2 -2
  32. package/components/slider/Slider.astro +3 -3
  33. package/components/tabs/Tabs.astro +2 -2
  34. package/components/testimonial/Testimonial.astro +1 -1
  35. package/dist/index.d.mts +11 -4
  36. package/dist/index.d.mts.map +1 -1
  37. package/dist/index.mjs +19 -7
  38. package/dist/index.mjs.map +1 -1
  39. package/mapping/classic.json +91 -1
  40. package/mapping/divi.json +22 -1
  41. package/mapping/elementor.json +46 -1
  42. package/mapping/gutenberg.json +70 -4
  43. package/package.json +2 -2
@@ -0,0 +1,106 @@
1
+ // What an embed URL becomes: the player/map address the iframe loads after a
2
+ // click, the page a plain link opens without JavaScript, and the provider.
3
+ // Only known providers get an iframe; anything else stays a link, so a page
4
+ // can never frame an arbitrary address that came in with the content.
5
+
6
+ export type EmbedProvider = 'youtube' | 'vimeo' | 'google-maps' | 'openstreetmap' | 'spotify' | 'soundcloud' | 'calendly' | 'typeform' | 'loom' | 'wistia'
7
+
8
+ export interface EmbedTarget {
9
+ provider: EmbedProvider
10
+ /** Loaded into the iframe on activation (autoplays where the provider allows it: the click was the consent). */
11
+ src: string
12
+ /** Opened by the plain link: the provider's own page. */
13
+ href: string
14
+ kind: 'video' | 'map' | 'audio' | 'widget'
15
+ /** YouTube Shorts and other vertical players. */
16
+ portrait?: boolean
17
+ }
18
+
19
+ const https = (value: string): URL | null => {
20
+ try {
21
+ const url = new URL(value.trim())
22
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url : null
23
+ }
24
+ catch { return null }
25
+ }
26
+
27
+ const YOUTUBE_ID = /^[\w-]{11}$/
28
+ /** `t=1m30s`, `t=90`, `start=90` → seconds. */
29
+ const seconds = (value: string | null): number => {
30
+ if (!value) return 0
31
+ if (/^\d+$/.test(value)) return Number(value)
32
+ const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value)
33
+ return m ? Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0) : 0
34
+ }
35
+
36
+ function youtube(url: URL): EmbedTarget | null {
37
+ const host = url.hostname.replace(/^(?:www|m)\./, '')
38
+ let id: string | null = null
39
+ let portrait = false
40
+ if (host === 'youtu.be') id = url.pathname.slice(1).split('/')[0] ?? null
41
+ else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
42
+ const [first, second] = url.pathname.split('/').filter(Boolean)
43
+ if (first === 'watch') id = url.searchParams.get('v')
44
+ else if (first === 'embed' || first === 'live' || first === 'v') id = second ?? null
45
+ else if (first === 'shorts') { id = second ?? null; portrait = true }
46
+ }
47
+ if (!id || !YOUTUBE_ID.test(id)) return null
48
+ const start = seconds(url.searchParams.get('t') ?? url.searchParams.get('start'))
49
+ const query = new URLSearchParams({ autoplay: '1', rel: '0' })
50
+ if (start) query.set('start', String(start))
51
+ return {
52
+ provider: 'youtube',
53
+ kind: 'video',
54
+ src: `https://www.youtube-nocookie.com/embed/${id}?${query}`,
55
+ href: portrait ? `https://www.youtube.com/shorts/${id}` : `https://www.youtube.com/watch?v=${id}${start ? `&t=${start}s` : ''}`,
56
+ ...(portrait ? { portrait } : {}),
57
+ }
58
+ }
59
+
60
+ function vimeo(url: URL): EmbedTarget | null {
61
+ const host = url.hostname.replace(/^www\./, '')
62
+ if (host !== 'vimeo.com' && host !== 'player.vimeo.com') return null
63
+ const parts = url.pathname.split('/').filter(Boolean)
64
+ const at = parts[0] === 'video' ? 1 : parts.findIndex(p => /^\d+$/.test(p))
65
+ const id = parts[at]
66
+ if (!id || !/^\d+$/.test(id)) return null
67
+ // Unlisted videos carry a hash: `vimeo.com/123/abcdef` or `?h=abcdef`.
68
+ const hash = url.searchParams.get('h') ?? (parts[at + 1] && /^[\da-f]+$/i.test(parts[at + 1]!) ? parts[at + 1]! : null)
69
+ const query = new URLSearchParams({ autoplay: '1', dnt: '1' })
70
+ if (hash) query.set('h', hash)
71
+ return { provider: 'vimeo', kind: 'video', src: `https://player.vimeo.com/video/${id}?${query}`, href: `https://vimeo.com/${id}${hash ? `/${hash}` : ''}` }
72
+ }
73
+
74
+ const GOOGLE = new Set(['www.google.com', 'google.com'])
75
+
76
+ /** Framed as given: the host is known to serve an embeddable page at that address. */
77
+ const FRAMED: Array<[EmbedProvider, EmbedTarget['kind'], (url: URL) => boolean]> = [
78
+ // Exact hosts only: a pattern over Google's country domains also admits look-alikes (`google.com.evil.example`).
79
+ ['google-maps', 'map', u => (GOOGLE.has(u.hostname) && u.pathname.startsWith('/maps/embed')) || (u.hostname === 'maps.google.com' && u.searchParams.get('output') === 'embed')],
80
+ ['openstreetmap', 'map', u => u.hostname === 'www.openstreetmap.org' && u.pathname === '/export/embed.html'],
81
+ ['spotify', 'audio', u => u.hostname === 'open.spotify.com' && u.pathname.startsWith('/embed/')],
82
+ ['soundcloud', 'audio', u => u.hostname === 'w.soundcloud.com' && u.pathname.startsWith('/player')],
83
+ ['calendly', 'widget', u => u.hostname === 'calendly.com'],
84
+ ['typeform', 'widget', u => /^[\w-]+\.typeform\.com$/.test(u.hostname) && u.pathname.startsWith('/to/')],
85
+ ['loom', 'video', u => /^(?:www\.)?loom\.com$/.test(u.hostname) && u.pathname.startsWith('/embed/')],
86
+ ['wistia', 'video', u => u.hostname === 'fast.wistia.net' && u.pathname.startsWith('/embed/')],
87
+ ]
88
+
89
+ /** A video page or player URL, or a known provider's embed address. `null` = not embeddable. */
90
+ export function embedTarget(value: string): EmbedTarget | null {
91
+ const url = https(value)
92
+ if (!url) return null
93
+ const found = youtube(url) ?? vimeo(url)
94
+ if (found) return found
95
+ url.protocol = 'https:'
96
+ for (const [provider, kind, test] of FRAMED) if (test(url)) return { provider, kind, src: url.href, href: url.href }
97
+ return null
98
+ }
99
+
100
+ /** A Google map of a place or address (what Elementor's and Divi's map widgets store). */
101
+ export function mapTarget(address: string): EmbedTarget | null {
102
+ const q = address.trim()
103
+ if (!q) return null
104
+ const query = encodeURIComponent(q)
105
+ return { provider: 'google-maps', kind: 'map', src: `https://www.google.com/maps?q=${query}&output=embed`, href: `https://www.google.com/maps/search/?api=1&query=${query}` }
106
+ }
@@ -0,0 +1,34 @@
1
+ [
2
+ {
3
+ "name": "youtube-poster",
4
+ "props": { "url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=1m5s", "title": "A walk through the new studio", "caption": "Filmed in spring, four minutes.", "poster": { "src": "/kit-fixtures/photo-2.svg", "alt": "", "width": 1200, "height": 800 } }
5
+ },
6
+ {
7
+ "name": "vimeo-plain-page",
8
+ "props": { "url": "https://vimeo.com/76979871", "title": "The making of the journal", "width": "page", "aspect": "video" }
9
+ },
10
+ {
11
+ "name": "shorts-auto-portrait",
12
+ "props": { "url": "https://youtube.com/shorts/aqz-KE-bpKQ", "title": "One-minute tip" }
13
+ },
14
+ {
15
+ "name": "forced-square",
16
+ "props": { "url": "https://youtu.be/aqz-KE-bpKQ", "title": "Square cut", "aspect": "square" }
17
+ },
18
+ {
19
+ "name": "forced-portrait",
20
+ "props": { "url": "https://youtu.be/aqz-KE-bpKQ", "title": "Vertical cut", "aspect": "portrait" }
21
+ },
22
+ {
23
+ "name": "map-address",
24
+ "props": { "address": "Rua Augusta 100, Lisbon", "title": "Our office", "showLabel": "Show map", "aspect": "map" }
25
+ },
26
+ {
27
+ "name": "spotify-strip",
28
+ "props": { "url": "https://open.spotify.com/embed/episode/7makk4oTQel546B0PZlDM5", "title": "Episode 12 — Slow travel" }
29
+ },
30
+ {
31
+ "name": "unknown-provider-link",
32
+ "props": { "url": "https://example.com/widget/42", "title": "Booking calendar" }
33
+ }
34
+ ]
@@ -0,0 +1,32 @@
1
+ {
2
+ "id": "embed",
3
+ "name": "Embed",
4
+ "description": "A video, map or player from another site (YouTube, Vimeo, Google Maps, OpenStreetMap, Spotify, SoundCloud, Calendly, Typeform, Loom, Wistia), loaded only on click: until then a poster link to the provider's page, with no iframe, no third-party request and no cookie (YouTube through youtube-nocookie.com). Unknown providers are never framed; they stay a plain link.",
5
+ "category": "content",
6
+ "uses": [],
7
+ "shared": ["KitImage.astro", "types.ts"],
8
+ "variants": {
9
+ "aspect": { "options": ["auto", "video", "portrait", "square", "map"], "default": "auto", "description": "auto: 16:9 for videos, 9:16 for YouTube Shorts, 4:3 (16:9 wide) for maps, a fixed strip for audio players. The others force a ratio." },
10
+ "width": { "options": ["prose", "page"], "default": "prose", "description": "prose: as wide as the reading column. page: the full page frame." }
11
+ },
12
+ "props": {
13
+ "url": { "type": "url", "description": "A video page (youtube.com/watch, youtu.be, /shorts/, vimeo.com/<id>) or a known provider's embed address (google.com/maps/embed, open.spotify.com/embed, w.soundcloud.com/player, calendly.com, *.typeform.com/to, loom.com/embed, fast.wistia.net/embed)." },
14
+ "address": { "type": "string", "description": "A place or address shown on a Google map when there is no url (Elementor google_maps address, Divi map address)." },
15
+ "title": { "type": "string", "description": "What it is: the player's accessible name and the poster's text." },
16
+ "caption": { "type": "string", "description": "A line under the embed." },
17
+ "poster": { "type": "object", "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } }, "description": "Local poster image (an ImageInput; its alt is not used, the link names the embed). Without one the poster is a plain dark panel. Never a provider thumbnail URL: that would be a third-party request before the click." },
18
+ "playLabel": { "type": "string", "description": "Poster action for a video or audio player (ui-strings embed.play)." },
19
+ "showLabel": { "type": "string", "description": "Poster action for a map or widget (ui-strings embed.show)." },
20
+ "id": { "type": "string", "description": "Element id." }
21
+ },
22
+ "slots": {},
23
+ "js": "vanilla",
24
+ "dependencies": {},
25
+ "sources": {
26
+ "gutenberg": ["core/embed"],
27
+ "elementor": ["elementor/video", "elementor/google_maps"],
28
+ "divi": ["divi/et_pb_video", "divi/et_pb_map"],
29
+ "classic": ["classic/youtube", "classic/vimeo", "classic/google-maps"]
30
+ },
31
+ "a11y": "The poster is one link named '<action>: <title>' that opens the provider's page without script. With script, Enter or a click puts the iframe in its place with the title as its accessible name and moves focus into it. Modified clicks (new tab) keep the link behaviour. The play icon is decorative."
32
+ }
@@ -14,10 +14,10 @@ export interface FaqItem {
14
14
 
15
15
  const faq = tv({
16
16
  slots: {
17
- heading: 'mb-8 text-2xl font-bold tracking-tight text-balance md:text-3xl',
17
+ heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
18
18
  list: 'flex flex-col',
19
19
  item: 'group',
20
- summary: 'flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg font-semibold [&::-webkit-details-marker]:hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
20
+ summary: 'flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg [font-weight:var(--font-weight-heading,600)] [&::-webkit-details-marker]:hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
21
21
  marker: 'size-5 shrink-0 transition-transform duration-200 group-open:rotate-45',
22
22
  answer: 'prose-site prose max-w-none pb-5 text-current',
23
23
  },
@@ -25,6 +25,8 @@ const faq = tv({
25
25
  style: {
26
26
  lines: { list: 'divide-y divide-line border-y border-line' },
27
27
  cards: { list: 'gap-3', item: 'rounded-card border border-line px-5', summary: 'py-5' },
28
+ // WordPress's details block: the browser's own disclosure triangle, no rules or box.
29
+ plain: { list: 'gap-2', summary: 'list-item list-[revert] py-1 text-base [font-weight:inherit] [&::-webkit-details-marker]:inline', marker: 'hidden', answer: 'pt-2 pl-5' },
28
30
  },
29
31
  tone: { default: {}, muted: { item: 'bg-surface' }, inverse: { answer: 'prose-invert' }, accent: { answer: 'prose-invert' } },
30
32
  },
@@ -37,7 +39,7 @@ const faq = tv({
37
39
  interface Props {
38
40
  items: FaqItem[]
39
41
  heading?: string | undefined
40
- style?: 'lines' | 'cards' | undefined
42
+ style?: 'lines' | 'cards' | 'plain' | undefined
41
43
  /** One answer open at a time (`<details name>`). */
42
44
  exclusive?: boolean | undefined
43
45
  /** Open the first answer. */
@@ -32,5 +32,9 @@
32
32
  {
33
33
  "name": "cards-accent",
34
34
  "props": { "style": "cards", "tone": "accent", "id": "faq-accent", "items": [{ "question": "How do I cancel my membership?", "answer": "<p>From your account page, at any time.</p>" }] }
35
+ },
36
+ {
37
+ "name": "plain",
38
+ "props": { "style": "plain", "id": "faq-plain", "items": [{ "question": "Do you accept guest essays?", "answer": "<p>Yes, send a short pitch first.</p>" }, { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month.</p>" }] }
35
39
  }
36
40
  ]
@@ -6,7 +6,7 @@
6
6
  "uses": [],
7
7
  "shared": ["Section.astro"],
8
8
  "variants": {
9
- "style": { "options": ["lines", "cards"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box." },
9
+ "style": { "options": ["lines", "cards", "plain"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box. plain: the browser's own disclosure triangle without rules or boxes (WordPress's details block)." },
10
10
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." }
11
11
  },
12
12
  "props": {
@@ -29,7 +29,7 @@ interface Props {
29
29
  siteName: string
30
30
  tagline?: string | undefined
31
31
  /** Link groups for the `columns` layout. */
32
- columns?: Array<{ title: string, links: LinkInput[] }> | undefined
32
+ columns?: Array<{ title?: string | undefined, label?: string | undefined, links: LinkInput[] }> | undefined
33
33
  /** A single row of links (the footer menu). */
34
34
  links?: LinkInput[] | undefined
35
35
  social?: LinkInput[] | undefined
@@ -54,8 +54,8 @@ const line = copyright ?? `© ${new Date().getFullYear()} ${siteName}`
54
54
  </div>
55
55
  {layout === 'columns'
56
56
  ? columns.map(column => (
57
- <nav aria-label={column.title}>
58
- <p class="mb-3 font-semibold">{column.title}</p>
57
+ <nav aria-label={column.label ?? column.title} data-cr-part="nav-footer">
58
+ {column.title && <p class="mb-3 font-semibold">{column.title}</p>}
59
59
  <ul class="flex flex-col gap-2">
60
60
  {column.links.map(item => <li><a href={item.href} class={link()}>{item.label}</a></li>)}
61
61
  </ul>
@@ -69,7 +69,7 @@ const line = copyright ?? `© ${new Date().getFullYear()} ${siteName}`
69
69
  </ul>
70
70
  )}
71
71
  {links.length > 0 && (
72
- <nav aria-label={navLabel}>
72
+ <nav aria-label={navLabel} data-cr-part="nav-footer">
73
73
  <ul class="flex flex-wrap gap-x-6 gap-y-2">
74
74
  {links.map(item => <li><a href={item.href} class={link()}>{item.label}</a></li>)}
75
75
  </ul>
@@ -4,28 +4,121 @@
4
4
  "description": "Site footer: brand and tagline, link columns or one row of links, social links and the copyright line.",
5
5
  "category": "chrome",
6
6
  "uses": [],
7
- "shared": ["types.ts"],
7
+ "shared": [
8
+ "types.ts"
9
+ ],
8
10
  "variants": {
9
- "layout": { "options": ["simple", "columns"], "default": "simple", "description": "simple: brand left, links and copyright right. columns: brand column plus one column per link group." },
10
- "tone": { "options": ["default", "muted", "inverse"], "default": "default", "description": "Surface with a top border, muted surface, or dark (ink) background." }
11
+ "layout": {
12
+ "options": [
13
+ "simple",
14
+ "columns"
15
+ ],
16
+ "default": "simple",
17
+ "description": "simple: brand left, links and copyright right. columns: brand column plus one column per link group."
18
+ },
19
+ "tone": {
20
+ "options": [
21
+ "default",
22
+ "muted",
23
+ "inverse"
24
+ ],
25
+ "default": "default",
26
+ "description": "Surface with a top border, muted surface, or dark (ink) background."
27
+ }
11
28
  },
12
29
  "props": {
13
- "siteName": { "type": "string", "required": true, "description": "Site title (site.title or site.organization)." },
14
- "tagline": { "type": "string", "description": "One line under the name (site.tagline)." },
15
- "columns": { "type": "array", "items": { "type": "object", "fields": { "title": { "type": "string", "required": true }, "links": { "type": "array", "required": true } } }, "description": "Link groups for the columns layout; each footer menu or widget area becomes one." },
16
- "links": { "type": "array", "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true } } }, "description": "One row of links (the footer menu) for the simple layout." },
17
- "social": { "type": "array", "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true } } }, "description": "Social profile links (site.same_as)." },
18
- "copyright": { "type": "string", "description": "Copyright line; defaults to © <year> <siteName>." },
19
- "navLabel": { "type": "string", "description": "Accessible name of the footer navigation (ui-strings nav.footer)." }
30
+ "siteName": {
31
+ "type": "string",
32
+ "required": true,
33
+ "description": "Site title (site.title or site.organization)."
34
+ },
35
+ "tagline": {
36
+ "type": "string",
37
+ "description": "One line under the name (site.tagline)."
38
+ },
39
+ "columns": {
40
+ "type": "array",
41
+ "items": {
42
+ "type": "object",
43
+ "fields": {
44
+ "title": {
45
+ "type": "string",
46
+ "description": "Heading shown above the links; leave out when the source shows none."
47
+ },
48
+ "label": {
49
+ "type": "string",
50
+ "description": "Accessible name of the column navigation (the menu name); defaults to the title."
51
+ },
52
+ "links": {
53
+ "type": "array",
54
+ "required": true
55
+ }
56
+ }
57
+ },
58
+ "description": "Link groups for the columns layout; each footer menu or widget area becomes one."
59
+ },
60
+ "links": {
61
+ "type": "array",
62
+ "items": {
63
+ "type": "object",
64
+ "fields": {
65
+ "label": {
66
+ "type": "string",
67
+ "required": true
68
+ },
69
+ "href": {
70
+ "type": "url",
71
+ "required": true
72
+ }
73
+ }
74
+ },
75
+ "description": "One row of links (the footer menu) for the simple layout."
76
+ },
77
+ "social": {
78
+ "type": "array",
79
+ "items": {
80
+ "type": "object",
81
+ "fields": {
82
+ "label": {
83
+ "type": "string",
84
+ "required": true
85
+ },
86
+ "href": {
87
+ "type": "url",
88
+ "required": true
89
+ }
90
+ }
91
+ },
92
+ "description": "Social profile links (site.same_as)."
93
+ },
94
+ "copyright": {
95
+ "type": "string",
96
+ "description": "Copyright line; defaults to © <year> <siteName>."
97
+ },
98
+ "navLabel": {
99
+ "type": "string",
100
+ "description": "Accessible name of the footer navigation (ui-strings nav.footer)."
101
+ }
20
102
  },
21
103
  "slots": {},
22
104
  "js": "none",
23
105
  "dependencies": {},
24
106
  "sources": {
25
- "gutenberg": ["core/template-part:footer", "core/social-links"],
26
- "elementor": ["elementor/social-icons", "elementor/icon-list"],
27
- "divi": ["divi/et_pb_social_media_follow"],
28
- "classic": ["classic/footer", "classic/widget-area"]
107
+ "gutenberg": [
108
+ "core/template-part:footer",
109
+ "core/social-links"
110
+ ],
111
+ "elementor": [
112
+ "elementor/social-icons",
113
+ "elementor/icon-list"
114
+ ],
115
+ "divi": [
116
+ "divi/et_pb_social_media_follow"
117
+ ],
118
+ "classic": [
119
+ "classic/footer",
120
+ "classic/widget-area"
121
+ ]
29
122
  },
30
123
  "a11y": "One <footer> landmark; each link group is a <nav> named by its title. Social links open in a new tab with rel=me."
31
124
  }
@@ -14,10 +14,10 @@ export interface GalleryItem {
14
14
 
15
15
  const gallery = tv({
16
16
  slots: {
17
- heading: 'mb-8 text-2xl font-bold tracking-tight md:text-3xl',
17
+ heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight md:text-3xl',
18
18
  list: 'gap-3 md:gap-4',
19
19
  item: 'm-0',
20
- frame: 'block w-full cursor-zoom-in overflow-hidden rounded-card focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
20
+ frame: 'block w-full cursor-zoom-in overflow-hidden rounded-[var(--radius-image,var(--radius-card))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
21
21
  thumb: 'h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]',
22
22
  caption: 'mt-2 text-sm opacity-75',
23
23
  viewer: 'm-auto max-h-[92vh] max-w-[92vw] bg-transparent p-0 backdrop:bg-ink/85',
@@ -74,7 +74,7 @@ const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 2
74
74
  {open === 'popover' && (
75
75
  <div id={viewer} popover class={s.viewer()}>
76
76
  <figure class="m-0 flex flex-col items-center gap-3">
77
- <KitImage image={item.image} sizes="92vw" class="max-h-[80vh] w-auto rounded-card object-contain" />
77
+ <KitImage image={item.image} sizes="92vw" class="max-h-[80vh] w-auto rounded-[var(--radius-image,var(--radius-card))] object-contain" />
78
78
  {item.caption && <figcaption class="text-sm text-surface">{item.caption}</figcaption>}
79
79
  <button type="button" popovertarget={viewer} popovertargetaction="hide" class="rounded-md border border-surface/40 px-4 py-2 text-sm text-surface hover:bg-surface/10">{closeLabel}</button>
80
80
  </figure>
@@ -55,7 +55,7 @@ const { root, inner, brand } = header({ layout, tone, position })
55
55
  {showName && <span>{siteName}</span>}
56
56
  </a>
57
57
  <div class:list={['flex items-center gap-4', layout === 'centered' ? 'justify-center' : 'ml-auto']}>
58
- <Nav items={items} label={navLabel} menuLabel={menuLabel} closeLabel={closeLabel} align={layout === 'centered' ? 'center' : 'end'} />
58
+ <Nav items={items} label={navLabel} menuLabel={menuLabel} closeLabel={closeLabel} part="nav-header" align={layout === 'centered' ? 'center' : 'end'} />
59
59
  {action && <Button action={action} size="sm" class="hidden md:inline-flex" />}
60
60
  </div>
61
61
  </div>
@@ -12,10 +12,10 @@ const hero = tv({
12
12
  grid: 'grid items-center gap-10',
13
13
  copy: 'flex flex-col gap-5',
14
14
  eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
15
- heading: 'text-4xl font-bold tracking-tight text-balance md:text-5xl lg:text-6xl',
15
+ heading: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-5xl lg:text-6xl',
16
16
  lead: 'max-w-2xl text-lg text-pretty opacity-85 md:text-xl',
17
17
  actions: 'flex flex-wrap gap-3 pt-2',
18
- media: 'overflow-hidden rounded-card',
18
+ media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
19
19
  },
20
20
  variants: {
21
21
  layout: {
@@ -25,8 +25,13 @@ const hero = tv({
25
25
  },
26
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
27
  tone: { default: {}, muted: {}, inverse: {}, accent: { eyebrow: 'opacity-100', lead: 'opacity-100' } },
28
+ // Where a cover sets its text over the image. WordPress covers centre it unless told otherwise.
29
+ align: { start: {}, center: {} },
28
30
  },
29
- defaultVariants: { layout: 'split', tone: 'default' },
31
+ compoundVariants: [
32
+ { layout: 'cover', align: 'center', class: { grid: 'justify-items-center', copy: 'mx-auto items-center text-center', lead: 'mx-auto', actions: 'justify-center' } },
33
+ ],
34
+ defaultVariants: { layout: 'split', tone: 'default', align: 'center' },
30
35
  })
31
36
 
32
37
  interface Props {
@@ -38,12 +43,14 @@ interface Props {
38
43
  actions?: ActionInput[] | undefined
39
44
  image?: ImageInput | undefined
40
45
  layout?: 'split' | 'centered' | 'cover' | undefined
46
+ /** Text position over a cover image; other layouts ignore it. */
47
+ align?: 'start' | 'center' | undefined
41
48
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
42
49
  id?: string | undefined
43
50
  }
44
51
 
45
- const { heading, level = 1, eyebrow, lead, actions = [], image, layout = 'split', tone, id } = Astro.props
46
- const s = hero({ layout, tone })
52
+ const { heading, level = 1, eyebrow, lead, actions = [], image, layout = 'split', align, tone, id } = Astro.props
53
+ const s = hero({ layout, align, tone })
47
54
  const Heading = level === 1 ? 'h1' : 'h2'
48
55
  const headingId = id ? `${id}-title` : undefined
49
56
  const cover = layout === 'cover' && image
@@ -58,7 +65,7 @@ const cover = layout === 'cover' && image
58
65
  <div class={s.copy()}>
59
66
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
60
67
  <Heading id={headingId} class={s.heading()}>{heading}</Heading>
61
- {lead && <p class={s.lead()}>{lead}</p>}
68
+ {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
62
69
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
63
70
  </div>
64
71
  </div>
@@ -71,7 +78,7 @@ const cover = layout === 'cover' && image
71
78
  <div class={s.copy()}>
72
79
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
73
80
  <Heading id={headingId} class={s.heading()}>{heading}</Heading>
74
- {lead && <p class={s.lead()}>{lead}</p>}
81
+ {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
75
82
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
76
83
  </div>
77
84
  {image && (
@@ -66,6 +66,27 @@
66
66
  }
67
67
  }
68
68
  },
69
+ {
70
+ "name": "cover-start",
71
+ "props": {
72
+ "heading": "The long way round",
73
+ "lead": "A year of trains across Europe, one station at a time.",
74
+ "layout": "cover",
75
+ "actions": [
76
+ {
77
+ "label": "Read the series",
78
+ "href": "/series/long-way/"
79
+ }
80
+ ],
81
+ "image": {
82
+ "src": "/kit-fixtures/photo-3.svg",
83
+ "alt": "",
84
+ "width": 1600,
85
+ "height": 900
86
+ },
87
+ "align": "start"
88
+ }
89
+ },
69
90
  {
70
91
  "name": "inverse-text-only",
71
92
  "props": {
@@ -29,6 +29,14 @@
29
29
  ],
30
30
  "default": "default",
31
31
  "description": "Section background (ignored by cover, which is always light text on the image)."
32
+ },
33
+ "align": {
34
+ "options": [
35
+ "start",
36
+ "center"
37
+ ],
38
+ "default": "center",
39
+ "description": "Text position over a cover image (layout cover only): centred, as WordPress covers default to, or at the start edge."
32
40
  }
33
41
  },
34
42
  "props": {
@@ -34,15 +34,17 @@ interface Props {
34
34
  density?: 'compact' | 'comfortable' | undefined
35
35
  /** Unique per page when a page has two navigations. */
36
36
  id?: string | undefined
37
+ /** What the navigation is, for checks that find a page's parts (`data-cr-part`, e.g. "nav-header"). */
38
+ part?: string | undefined
37
39
  }
38
40
 
39
- const { items, label = 'Main', menuLabel = 'Menu', closeLabel = 'Close', mobile = 'popover', align, density, id = 'site-nav' } = Astro.props
41
+ const { items, label = 'Main', menuLabel = 'Menu', closeLabel = 'Close', mobile = 'popover', align, density, id = 'site-nav', part } = Astro.props
40
42
  const { list, link, sub, toggle } = nav({ align, density })
41
43
  const current = Astro.url.pathname
42
44
  const drawer = `${id}-drawer`
43
45
  ---
44
46
  {items.length > 0 && (
45
- <nav aria-label={label} class="relative">
47
+ <nav aria-label={label} data-cr-part={part} class="relative">
46
48
  <ul class={list()}>
47
49
  {items.map(item => (
48
50
  <li class="group relative">