@contentrain/astro-kit 0.5.0 → 0.6.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 (66) hide show
  1. package/catalog.json +1512 -167
  2. package/components/_shared/KitImage.astro +10 -4
  3. package/components/_shared/image.ts +14 -0
  4. package/components/_shared/types.ts +2 -1
  5. package/components/announcement/Announcement.astro +53 -0
  6. package/components/announcement/fixtures.json +6 -0
  7. package/components/announcement/meta.json +53 -0
  8. package/components/card-grid/CardGrid.astro +5 -3
  9. package/components/card-grid/meta.json +25 -11
  10. package/components/consent/Consent.astro +87 -0
  11. package/components/consent/fixtures.json +4 -0
  12. package/components/consent/meta.json +67 -0
  13. package/components/contact-details/ContactDetails.astro +74 -0
  14. package/components/contact-details/fixtures.json +4 -0
  15. package/components/contact-details/meta.json +105 -0
  16. package/components/contact-form/ContactForm.astro +22 -6
  17. package/components/contact-form/fixtures.json +2 -1
  18. package/components/contact-form/meta.json +7 -6
  19. package/components/cta/meta.json +4 -4
  20. package/components/faq/meta.json +6 -6
  21. package/components/feature-list/FeatureList.astro +85 -0
  22. package/components/feature-list/fixtures.json +129 -0
  23. package/components/feature-list/meta.json +118 -0
  24. package/components/footer/Footer.astro +14 -6
  25. package/components/footer/fixtures.json +3 -1
  26. package/components/footer/meta.json +12 -0
  27. package/components/gallery/Gallery.astro +7 -3
  28. package/components/gallery/meta.json +6 -6
  29. package/components/header/Header.astro +19 -8
  30. package/components/header/fixtures.json +2 -1
  31. package/components/hero/meta.json +30 -8
  32. package/components/logo-cloud/LogoCloud.astro +69 -0
  33. package/components/logo-cloud/fixtures.json +139 -0
  34. package/components/logo-cloud/meta.json +107 -0
  35. package/components/newsletter/meta.json +12 -0
  36. package/components/pricing/Pricing.astro +94 -0
  37. package/components/pricing/fixtures.json +6 -0
  38. package/components/pricing/meta.json +133 -0
  39. package/components/prose/Prose.astro +7 -3
  40. package/components/prose/fixtures.json +2 -1
  41. package/components/prose/meta.json +5 -2
  42. package/components/slider/Slider.astro +15 -3
  43. package/components/slider/meta.json +8 -8
  44. package/components/split/Split.astro +64 -0
  45. package/components/split/fixtures.json +6 -0
  46. package/components/split/meta.json +154 -0
  47. package/components/stats/Stats.astro +66 -0
  48. package/components/stats/fixtures.json +98 -0
  49. package/components/stats/meta.json +113 -0
  50. package/components/steps/Steps.astro +77 -0
  51. package/components/steps/fixtures.json +5 -0
  52. package/components/steps/meta.json +105 -0
  53. package/components/team/Team.astro +82 -0
  54. package/components/team/fixtures.json +5 -0
  55. package/components/team/meta.json +116 -0
  56. package/components/testimonial/Testimonial.astro +5 -3
  57. package/components/testimonial/meta.json +21 -20
  58. package/dist/index.d.mts +115 -2
  59. package/dist/index.d.mts.map +1 -1
  60. package/dist/index.mjs +175 -3
  61. package/dist/index.mjs.map +1 -1
  62. package/mapping/classic.json +51 -6
  63. package/mapping/divi.json +359 -9
  64. package/mapping/elementor.json +581 -8
  65. package/mapping/gutenberg.json +420 -4
  66. package/package.json +2 -2
@@ -4,11 +4,14 @@
4
4
  // StudioForm): its fields, spam protection and notifications live in Studio,
5
5
  // and submissions arrive there for review — no form plugin, no mail server.
6
6
  // Until the site is bound to a Studio project the form renders nothing and the
7
- // contact details still show.
7
+ // contact details still show. When the owner chose another home for forms at
8
+ // migration (site.config.ts `features.forms`), the same place holds a form
9
+ // that posts to their form service, an address to write to, or a link to the
10
+ // form on WordPress.
8
11
  import { tv } from 'tailwind-variants'
9
12
  import Section from '../_shared/Section.astro'
10
13
  import StudioForm from '../../studio/StudioForm.astro'
11
- import { siteConfig } from '../../../site.config'
14
+ import { formHome } from '../../../lib/features'
12
15
 
13
16
  export interface ContactDetail {
14
17
  label: string
@@ -26,6 +29,8 @@ const contact = tv({
26
29
  details: 'mt-2 flex flex-col gap-4',
27
30
  term: 'text-sm font-semibold opacity-75',
28
31
  value: 'text-current',
32
+ formColumn: 'flex flex-col gap-4',
33
+ formHeading: 'text-xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-2xl',
29
34
  form: 'rounded-card',
30
35
  },
31
36
  variants: {
@@ -44,17 +49,21 @@ interface Props {
44
49
  model: string
45
50
  heading?: string | undefined
46
51
  intro?: string | undefined
52
+ /** The form's own heading, above the form ("Tell us about your project"). */
53
+ formHeading?: string | undefined
47
54
  details?: ContactDetail[] | undefined
48
55
  layout?: 'split' | 'stacked' | undefined
49
56
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
50
57
  id?: string | undefined
51
58
  }
52
59
 
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)
60
+ const { model, heading, intro, formHeading, details = [], layout, tone, id = 'contact' } = Astro.props
61
+ // Nothing to render (Studio not bound yet): a split would leave an empty column — the details stand alone.
62
+ const formShown = formHome(model) !== null
56
63
  const s = contact({ layout: formShown ? layout : 'stacked', tone })
57
64
  const headingId = heading ? `${id}-title` : undefined
65
+ // One level under the section heading, or the section's heading when there is none.
66
+ const FormHeading = heading ? 'h3' : 'h2'
58
67
  ---
59
68
  <Section tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
60
69
  <div class={s.grid()}>
@@ -74,6 +83,13 @@ const headingId = heading ? `${id}-title` : undefined
74
83
  )}
75
84
  </div>
76
85
  )}
77
- {formShown && <StudioForm model={model} class={s.form()} />}
86
+ {formShown && (formHeading
87
+ ? (
88
+ <div class={s.formColumn()}>
89
+ <FormHeading class={s.formHeading()}>{formHeading}</FormHeading>
90
+ <StudioForm model={model} class={s.form()} />
91
+ </div>
92
+ )
93
+ : <StudioForm model={model} class={s.form()} />)}
78
94
  </div>
79
95
  </Section>
@@ -14,5 +14,6 @@
14
14
  },
15
15
  { "name": "stacked-muted", "props": { "model": "contact-submissions", "heading": "Write to us", "layout": "stacked", "tone": "muted", "id": "write" } },
16
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" } }
17
+ { "name": "stacked-accent", "props": { "model": "newsletter-requests", "heading": "Request a print copy", "layout": "stacked", "tone": "accent", "id": "print" } },
18
+ { "name": "form-heading", "props": { "model": "contact-submissions", "heading": "Contact", "intro": "Tell us where you are and what you need.", "formHeading": "Tell us about your project", "details": [{ "label": "Address", "value": "12 Harbour Street, Leith" }] } }
18
19
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "contact-form",
3
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.",
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. When the owner chose another home at migration (site.config.ts `features.forms`) the form posts to their form service, or the section shows an address to write to or a link to the form on WordPress.",
5
5
  "category": "section",
6
6
  "uses": [],
7
7
  "shared": ["Section.astro"],
@@ -10,11 +10,12 @@
10
10
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background; the form always sits on a surface card." }
11
11
  },
12
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`." }
13
+ "model": { "type": "string", "required": true, "content": false, "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", "label": "Heading", "description": "Section heading." },
15
+ "intro": { "type": "text", "label": "Introduction", "description": "A line or two above the details." },
16
+ "formHeading": { "type": "string", "label": "Form heading", "description": "The form's own heading, shown above the form." },
17
+ "details": { "type": "array", "label": "Contact details", "items": { "type": "object", "fields": { "label": { "type": "string", "label": "Label", "required": true, "description": "What the detail is (Phone, Email, Address)." }, "value": { "type": "string", "label": "Value", "required": true, "description": "The detail as shown." }, "href": { "type": "url", "label": "Link", "description": "A tel:, mailto: or map link for it." } } }, "description": "Ways to reach you, in order (Email → mailto:, Phone → tel:, Address → map link)." },
18
+ "id": { "type": "string", "content": false, "description": "Section id; the heading gets `<id>-title`." }
18
19
  },
19
20
  "slots": {},
20
21
  "js": "studio",
@@ -11,10 +11,10 @@
11
11
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "accent", "description": "Background. On accent, primary buttons are drawn in the secondary style so they stay visible." }
12
12
  },
13
13
  "props": {
14
- "heading": { "type": "string", "required": true, "description": "The call's heading (Divi CTA title, Elementor call-to-action title)." },
15
- "body": { "type": "text", "description": "One or two sentences under the heading." },
16
- "actions": { "type": "array", "required": true, "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true }, "newTab": { "type": "boolean" }, "style": { "type": "select", "options": ["primary", "secondary", "ghost"] } } }, "description": "Buttons, in order; usually one." },
17
- "id": { "type": "string", "description": "Anchor id of the section; the heading gets `<id>-title`." }
14
+ "heading": { "type": "string", "label": "Heading", "required": true, "description": "The call's heading (Divi CTA title, Elementor call-to-action title)." },
15
+ "body": { "type": "text", "label": "Text", "description": "One or two sentences under the heading." },
16
+ "actions": { "type": "array", "label": "Buttons", "required": true, "items": { "type": "object", "fields": { "label": { "type": "string", "label": "Label", "required": true, "description": "The button text." }, "href": { "type": "url", "label": "Link", "required": true, "description": "Where the button goes: a page on this site or a full address." }, "newTab": { "type": "boolean", "label": "Open in a new tab", "description": "Open the link in a new tab (an external site the reader should not leave for)." }, "style": { "type": "select", "label": "Style", "options": ["primary", "secondary", "ghost"], "description": "primary for the main action, secondary or ghost for the ones beside it." } } }, "description": "Buttons, in order; usually one." },
17
+ "id": { "type": "string", "content": false, "description": "Anchor id of the section; the heading gets `<id>-title`." }
18
18
  },
19
19
  "slots": {},
20
20
  "js": "none",
@@ -10,12 +10,12 @@
10
10
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." }
11
11
  },
12
12
  "props": {
13
- "items": { "type": "array", "required": true, "items": { "type": "object", "fields": { "question": { "type": "string", "required": true }, "answer": { "type": "richtext", "required": true } } }, "description": "The questions in order (core/details summary + content, Elementor accordion/toggle tabs, Divi accordion/toggle items)." },
14
- "heading": { "type": "string", "description": "Section heading." },
15
- "exclusive": { "type": "boolean", "default": false, "description": "One answer open at a time (Elementor accordion, Divi accordion). Off for toggles." },
16
- "openFirst": { "type": "boolean", "default": false, "description": "The first answer starts open (Divi open_toggle, Elementor active item 1)." },
17
- "schema": { "type": "boolean", "default": false, "description": "Emit FAQPage JSON-LD built from these items. Use on at most one FAQ per page, and only when the page is really a FAQ." },
18
- "id": { "type": "string", "description": "Section id; also the exclusive group's name." }
13
+ "items": { "type": "array", "label": "Questions", "required": true, "items": { "type": "object", "fields": { "question": { "type": "string", "label": "Question", "required": true, "description": "The question, as the visitor would ask it." }, "answer": { "type": "richtext", "label": "Answer", "required": true, "description": "The answer; links and lists allowed." } } }, "description": "The questions in order (core/details summary + content, Elementor accordion/toggle tabs, Divi accordion/toggle items)." },
14
+ "heading": { "type": "string", "label": "Heading", "description": "Section heading." },
15
+ "exclusive": { "type": "boolean", "default": false, "content": false, "description": "One answer open at a time (Elementor accordion, Divi accordion). Off for toggles." },
16
+ "openFirst": { "type": "boolean", "default": false, "content": false, "description": "The first answer starts open (Divi open_toggle, Elementor active item 1)." },
17
+ "schema": { "type": "boolean", "default": false, "content": false, "description": "Emit FAQPage JSON-LD built from these items. Use on at most one FAQ per page, and only when the page is really a FAQ." },
18
+ "id": { "type": "string", "content": false, "description": "Section id; also the exclusive group's name." }
19
19
  },
20
20
  "slots": {},
21
21
  "js": "none",
@@ -0,0 +1,85 @@
1
+ ---
2
+ // A list of short features — a title and a line of text each — marked with
3
+ // the feature's icon or a shared check, under an optional heading and intro.
4
+ import { tv } from 'tailwind-variants'
5
+ import Section, { type Tone } from '../_shared/Section.astro'
6
+
7
+ const list = tv({
8
+ slots: {
9
+ grid: 'grid gap-x-8 gap-y-10 sm:grid-cols-2',
10
+ item: 'flex gap-4',
11
+ mark: 'flex size-6 shrink-0 items-center justify-center text-accent',
12
+ title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
13
+ text: 'mt-1 opacity-80',
14
+ },
15
+ variants: {
16
+ columns: {
17
+ '2': {},
18
+ '3': { grid: 'md:grid-cols-3' },
19
+ '4': { grid: 'lg:grid-cols-4' },
20
+ },
21
+ style: {
22
+ icon: { mark: 'size-10 rounded-[var(--radius-card)] bg-accent/10' },
23
+ check: {},
24
+ },
25
+ // On accent the mark takes the ink colour, and text is never dimmed.
26
+ tone: { default: {}, muted: {}, inverse: {}, accent: { mark: 'text-current', text: 'opacity-100' } },
27
+ },
28
+ defaultVariants: { columns: '3', style: 'icon', tone: 'default' },
29
+ })
30
+
31
+ // 24×24 stroke paths (Lucide-style); unknown names fall back to check.
32
+ const icons: Record<string, string> = {
33
+ check: 'M20 6 9 17l-5-5',
34
+ star: 'm12 2 3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z',
35
+ bolt: 'M13 2 3 14h9l-1 8 10-12h-9z',
36
+ shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
37
+ heart: 'M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7z',
38
+ globe: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20',
39
+ clock: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 6v6l4 2',
40
+ chat: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z',
41
+ }
42
+
43
+ interface Item {
44
+ title: string
45
+ text?: string | undefined
46
+ /** A kit icon name (check, star, bolt, shield, heart, globe, clock, chat). */
47
+ icon?: string | undefined
48
+ }
49
+
50
+ interface Props {
51
+ heading?: string | undefined
52
+ intro?: string | undefined
53
+ items: Item[]
54
+ columns?: '2' | '3' | '4' | undefined
55
+ style?: 'icon' | 'check' | undefined
56
+ tone?: Tone | undefined
57
+ id?: string | undefined
58
+ }
59
+
60
+ const { heading, intro, items, columns, style = 'icon', tone, id } = Astro.props
61
+ const s = list({ columns, style, tone })
62
+ const headingId = heading && id ? `${id}-title` : undefined
63
+ const pathOf = (item: Item) => (style === 'icon' && item.icon && icons[item.icon]) || icons.check
64
+ ---
65
+ <Section tone={tone} id={id} aria-labelledby={headingId}>
66
+ {(heading || intro) && (
67
+ <header class="mb-10 flex max-w-2xl flex-col gap-3">
68
+ {heading && <h2 id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{heading}</h2>}
69
+ {intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']} data-kit-text>{intro}</p>}
70
+ </header>
71
+ )}
72
+ <ul class={s.grid()}>
73
+ {items.map(item => (
74
+ <li class={s.item()}>
75
+ <span class={s.mark()} aria-hidden="true">
76
+ <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>
77
+ </span>
78
+ <div>
79
+ <h3 class={s.title()}>{item.title}</h3>
80
+ {item.text && <p class={s.text()} data-kit-text>{item.text}</p>}
81
+ </div>
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ </Section>
@@ -0,0 +1,129 @@
1
+ [
2
+ {
3
+ "name": "three-icon",
4
+ "props": {
5
+ "heading": "Why teams switch",
6
+ "intro": "Everything a small site needs, nothing it doesn't.",
7
+ "items": [
8
+ {
9
+ "title": "Fast by default",
10
+ "text": "Static pages served from the edge.",
11
+ "icon": "bolt"
12
+ },
13
+ {
14
+ "title": "Secure",
15
+ "text": "No database to break into.",
16
+ "icon": "shield"
17
+ },
18
+ {
19
+ "title": "Global",
20
+ "text": "Every language your readers speak.",
21
+ "icon": "globe"
22
+ },
23
+ {
24
+ "title": "Loved by editors",
25
+ "text": "Review changes before they go live.",
26
+ "icon": "heart"
27
+ },
28
+ {
29
+ "title": "Always on time",
30
+ "icon": "clock"
31
+ },
32
+ {
33
+ "title": "Real support",
34
+ "text": "Talk to the people who built it.",
35
+ "icon": "chat"
36
+ }
37
+ ]
38
+ }
39
+ },
40
+ {
41
+ "name": "two-check-muted",
42
+ "props": {
43
+ "heading": "Included",
44
+ "items": [
45
+ {
46
+ "title": "Fast by default",
47
+ "text": "Static pages served from the edge.",
48
+ "icon": "bolt"
49
+ },
50
+ {
51
+ "title": "Secure",
52
+ "text": "No database to break into.",
53
+ "icon": "shield"
54
+ },
55
+ {
56
+ "title": "Global",
57
+ "text": "Every language your readers speak.",
58
+ "icon": "globe"
59
+ },
60
+ {
61
+ "title": "Loved by editors",
62
+ "text": "Review changes before they go live.",
63
+ "icon": "heart"
64
+ }
65
+ ],
66
+ "columns": "2",
67
+ "style": "check",
68
+ "tone": "muted"
69
+ }
70
+ },
71
+ {
72
+ "name": "four-inverse",
73
+ "props": {
74
+ "items": [
75
+ {
76
+ "title": "Fast by default",
77
+ "text": "Static pages served from the edge.",
78
+ "icon": "bolt"
79
+ },
80
+ {
81
+ "title": "Secure",
82
+ "text": "No database to break into.",
83
+ "icon": "shield"
84
+ },
85
+ {
86
+ "title": "Global",
87
+ "text": "Every language your readers speak.",
88
+ "icon": "globe"
89
+ },
90
+ {
91
+ "title": "Loved by editors",
92
+ "text": "Review changes before they go live.",
93
+ "icon": "heart"
94
+ },
95
+ {
96
+ "title": "Unknown icon falls back",
97
+ "icon": "nope"
98
+ }
99
+ ],
100
+ "columns": "4",
101
+ "tone": "inverse"
102
+ }
103
+ },
104
+ {
105
+ "name": "accent-check",
106
+ "props": {
107
+ "heading": "What you get",
108
+ "items": [
109
+ {
110
+ "title": "Fast by default",
111
+ "text": "Static pages served from the edge.",
112
+ "icon": "bolt"
113
+ },
114
+ {
115
+ "title": "Secure",
116
+ "text": "No database to break into.",
117
+ "icon": "shield"
118
+ },
119
+ {
120
+ "title": "Global",
121
+ "text": "Every language your readers speak.",
122
+ "icon": "globe"
123
+ }
124
+ ],
125
+ "style": "check",
126
+ "tone": "accent"
127
+ }
128
+ }
129
+ ]
@@ -0,0 +1,118 @@
1
+ {
2
+ "id": "feature-list",
3
+ "name": "Feature list",
4
+ "description": "A list of short features, each a title and a line of text marked with an icon or a check, under an optional heading and intro. Lighter than a card grid: no images, no links.",
5
+ "category": "section",
6
+ "uses": [],
7
+ "shared": [
8
+ "Section.astro"
9
+ ],
10
+ "variants": {
11
+ "columns": {
12
+ "options": [
13
+ "2",
14
+ "3",
15
+ "4"
16
+ ],
17
+ "default": "3",
18
+ "description": "Features per row on wide screens; one on phones, two on tablets."
19
+ },
20
+ "style": {
21
+ "options": [
22
+ "icon",
23
+ "check"
24
+ ],
25
+ "default": "icon",
26
+ "description": "icon: each feature's own icon in a tinted badge. check: the same check mark before every feature."
27
+ },
28
+ "tone": {
29
+ "options": [
30
+ "default",
31
+ "muted",
32
+ "inverse",
33
+ "accent"
34
+ ],
35
+ "default": "default",
36
+ "description": "Section background."
37
+ }
38
+ },
39
+ "props": {
40
+ "heading": {
41
+ "type": "string",
42
+ "label": "Heading",
43
+ "description": "Section heading."
44
+ },
45
+ "intro": {
46
+ "type": "text",
47
+ "label": "Introduction",
48
+ "description": "A sentence under the heading."
49
+ },
50
+ "items": {
51
+ "type": "array",
52
+ "label": "Features",
53
+ "required": true,
54
+ "items": {
55
+ "type": "object",
56
+ "fields": {
57
+ "title": {
58
+ "type": "string",
59
+ "label": "Title",
60
+ "required": true,
61
+ "description": "The feature, in a few words."
62
+ },
63
+ "text": {
64
+ "type": "text",
65
+ "label": "Text",
66
+ "description": "A sentence about it."
67
+ },
68
+ "icon": {
69
+ "type": "select",
70
+ "label": "Icon",
71
+ "options": [
72
+ "check",
73
+ "star",
74
+ "bolt",
75
+ "shield",
76
+ "heart",
77
+ "globe",
78
+ "clock",
79
+ "chat"
80
+ ],
81
+ "description": "The kit icon shown with the icon style; check when absent."
82
+ }
83
+ }
84
+ },
85
+ "description": "The features, in order (Elementor icon list or icon boxes without images, Gutenberg list of features, Divi blurbs with icons)."
86
+ },
87
+ "id": {
88
+ "type": "string",
89
+ "content": false,
90
+ "description": "Anchor id of the section; the heading gets `<id>-title`."
91
+ }
92
+ },
93
+ "slots": {},
94
+ "js": "none",
95
+ "dependencies": {},
96
+ "sources": {
97
+ "gutenberg": [
98
+ "core/list"
99
+ ],
100
+ "elementor": [
101
+ "elementor/icon-list",
102
+ "elementor/icon-box"
103
+ ],
104
+ "divi": [
105
+ "divi/et_pb_blurb"
106
+ ]
107
+ },
108
+ "a11y": "A list; icons are decorative (aria-hidden), the title and text carry the meaning. The section is labelled by its heading.",
109
+ "pattern": {
110
+ "summary": "repeated icon + short title + line of text",
111
+ "signals": [
112
+ "3+ repeated items",
113
+ "icons",
114
+ "no images",
115
+ "short text"
116
+ ]
117
+ }
118
+ }
@@ -21,8 +21,12 @@ const footer = tv({
21
21
  muted: { root: 'bg-surface-muted text-ink' },
22
22
  inverse: { root: 'bg-ink text-surface' },
23
23
  },
24
+ align: { start: {}, center: { inner: 'items-center justify-items-center text-center md:items-center [&_ul]:justify-center' } },
24
25
  },
25
- defaultVariants: { layout: 'simple', tone: 'default' },
26
+ compoundVariants: [
27
+ { layout: 'simple', align: 'center', class: { inner: 'md:flex-col md:justify-center' } },
28
+ ],
29
+ defaultVariants: { layout: 'simple', tone: 'default', align: 'start' },
26
30
  })
27
31
 
28
32
  interface Props {
@@ -35,23 +39,27 @@ interface Props {
35
39
  social?: LinkInput[] | undefined
36
40
  /** The copyright line, e.g. "© 2026 Northwind Studio"; `{year}` is the current year. */
37
41
  copyright?: string | undefined
42
+ /** Label of the cookie settings link after the copyright line ("Cookie settings"); it reopens the consent bar, so a visitor can withdraw consent as easily as they gave it. Only with a consent bar on the page. */
43
+ consentLabel?: string | undefined
38
44
  /** Accessible name of the footer navigation ("Footer"). */
39
45
  navLabel?: string | undefined
40
46
  layout?: 'simple' | 'columns' | undefined
41
47
  tone?: 'default' | 'muted' | 'inverse' | undefined
48
+ align?: 'start' | 'center' | undefined
42
49
  }
43
50
 
44
- const { siteName, tagline, columns = [], links = [], social = [], copyright, navLabel = 'Footer', layout = 'simple', tone } = Astro.props
45
- const { root, inner, muted, link } = footer({ layout, tone })
51
+ const { siteName, tagline, columns = [], links = [], social = [], copyright, consentLabel, navLabel = 'Footer', layout = 'simple', tone, align } = Astro.props
52
+ const { root, inner, muted, link } = footer({ layout, tone, align })
46
53
  const year = String(new Date().getFullYear())
47
54
  const line = copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
48
55
  ---
56
+ <!-- The cookie settings control is a button, not a link: the Consent component's listener reopens its bar on any [data-kit-consent-open]. -->
49
57
  <footer class={root()}>
50
58
  <div class={inner()} data-kit-footer style={layout === 'columns' ? `--footer-columns: ${Math.max(columns.length, 1)}` : undefined}>
51
59
  <div class="flex flex-col gap-3">
52
60
  <p class="text-base font-semibold" data-kit-brand>{siteName}</p>
53
61
  {tagline && <p class={muted()} data-kit-tagline>{tagline}</p>}
54
- {layout === 'columns' && <p class:list={[muted(), 'mt-auto pt-6']}>{line}</p>}
62
+ {layout === 'columns' && <p class:list={[muted(), 'mt-auto pt-6']}>{line}{consentLabel && <> · <button type="button" data-kit-consent-open class={link()}>{consentLabel}</button></>}</p>}
55
63
  </div>
56
64
  {layout === 'columns'
57
65
  ? columns.map(column => (
@@ -63,7 +71,7 @@ const line = copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
63
71
  </nav>
64
72
  ))
65
73
  : (
66
- <div class="flex flex-col gap-4 md:items-end">
74
+ <div class:list={['flex flex-col gap-4', align === 'center' ? 'items-center' : 'md:items-end']}>
67
75
  {social.length > 0 && (
68
76
  <ul class="flex flex-wrap gap-4">
69
77
  {social.map(item => <li><a href={item.href} rel="me noopener" target="_blank" class={link()}>{item.label}</a></li>)}
@@ -76,7 +84,7 @@ const line = copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
76
84
  </ul>
77
85
  </nav>
78
86
  )}
79
- <p class={muted()}>{line}</p>
87
+ <p class={muted()}>{line}{consentLabel && <> · <button type="button" data-kit-consent-open class={link()}>{consentLabel}</button></>}</p>
80
88
  </div>
81
89
  )}
82
90
  {layout === 'columns' && social.length > 0 && (
@@ -1,5 +1,7 @@
1
1
  [
2
2
  { "name": "simple", "props": { "siteName": "Northwind Studio", "copyright": "© 2026 Northwind Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Privacy", "href": "/privacy/" }, { "label": "RSS feed", "href": "/rss.xml" }] } },
3
3
  { "name": "columns-inverse", "props": { "siteName": "Northwind Studio", "tagline": "Calm, fast websites for teams who write.", "layout": "columns", "tone": "inverse", "copyright": "© 2026 Northwind Studio", "columns": [{ "title": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Team", "href": "/about/team/" }] }, { "title": "Journal", "links": [{ "label": "Guides", "href": "/category/guides/" }, { "label": "News", "href": "/category/news/" }] }, { "title": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }], "social": [{ "label": "GitHub", "href": "https://github.com/contentrain" }, { "label": "Mastodon", "href": "https://mastodon.social/@contentrain" }] } },
4
- { "name": "muted", "props": { "siteName": "Northwind", "tone": "muted", "copyright": "© 2026 Northwind", "links": [{ "label": "Imprint", "href": "/imprint/" }] } }
4
+ { "name": "muted", "props": { "siteName": "Northwind", "tone": "muted", "copyright": "© 2026 Northwind", "links": [{ "label": "Imprint", "href": "/imprint/" }] } },
5
+ { "name": "centered", "props": { "siteName": "Golden Agency", "tagline": "Websites for makers", "align": "center", "copyright": "© 2026 Golden Agency", "links": [{ "label": "Services", "href": "/services/" }, { "label": "Contact", "href": "/contact/" }], "social": [{ "label": "Instagram", "href": "https://instagram.com/" }] } },
6
+ { "name": "consent-settings", "props": { "siteName": "Golden Agency", "copyright": "© 2026 Golden Agency", "consentLabel": "Cookie settings", "links": [{ "label": "Privacy", "href": "/privacy/" }] } }
5
7
  ]
@@ -24,6 +24,14 @@
24
24
  ],
25
25
  "default": "default",
26
26
  "description": "Surface with a top border, muted surface, or dark (ink) background."
27
+ },
28
+ "align": {
29
+ "options": [
30
+ "start",
31
+ "center"
32
+ ],
33
+ "default": "start",
34
+ "description": "start: brand and links to the sides (left-aligned on phones). center: everything centred in one column, as themes with a centred footer print it."
27
35
  }
28
36
  },
29
37
  "props": {
@@ -95,6 +103,10 @@
95
103
  "type": "string",
96
104
  "description": "Copyright line; {year} is the current year. Defaults to © <year> <siteName>."
97
105
  },
106
+ "consentLabel": {
107
+ "type": "string",
108
+ "description": "Label of the cookie settings button after the copyright line; it reopens the consent bar (withdrawal, GDPR 7(3)). Set only when the site has a consent bar."
109
+ },
98
110
  "navLabel": {
99
111
  "type": "string",
100
112
  "description": "Accessible name of the footer navigation (ui-strings nav.footer)."
@@ -5,10 +5,13 @@
5
5
  import { tv } from 'tailwind-variants'
6
6
  import KitImage from '../_shared/KitImage.astro'
7
7
  import Section from '../_shared/Section.astro'
8
- import type { ImageInput } from '../_shared/types'
8
+ import { imageOf, type ImageSource } from '../_shared/image'
9
+ import { type ImageInput } from '../_shared/types'
9
10
 
10
11
  export interface GalleryItem {
11
- image: ImageInput
12
+ /** The image's address (or, until 0.7, an `ImageInput`). */
13
+ image: ImageSource
14
+ imageAlt?: string | undefined
12
15
  caption?: string | undefined
13
16
  }
14
17
 
@@ -50,7 +53,8 @@ interface Props {
50
53
  id?: string | undefined
51
54
  }
52
55
 
53
- const { items, heading, layout, columns, open = 'popover', closeLabel = 'Close', tone, id = 'gallery' } = Astro.props
56
+ const { items: given, heading, layout, columns, open = 'popover', closeLabel = 'Close', tone, id = 'gallery' } = Astro.props
57
+ const items = given.flatMap(item => { const image = imageOf(item.image, item.imageAlt); return image ? [{ ...item, image }] : [] }) satisfies Array<{ image: ImageInput, caption?: string | undefined }>
54
58
  const s = gallery({ layout, columns, open })
55
59
  const headingId = heading ? `${id}-title` : undefined
56
60
  const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 25vw, 50vw' : '(min-width: 768px) 33vw, 50vw'
@@ -4,18 +4,18 @@
4
4
  "description": "Images in a square grid or in masonry columns, with optional captions. A click shows the image large in a native popover (no script), opens the image file, or does nothing.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["KitImage.astro", "Section.astro", "types.ts"],
7
+ "shared": ["KitImage.astro", "Section.astro", "image.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["grid", "masonry"], "default": "grid", "description": "grid: square crops in rows. masonry: columns that keep each image's shape." },
10
10
  "columns": { "options": ["2", "3", "4"], "default": "3", "description": "Columns on wide screens; phones show two." },
11
11
  "open": { "options": ["popover", "link", "none"], "default": "popover", "description": "popover: the image opens large in place (Popover API; Escape or a click outside closes it). link: the thumbnail links to the image file (the WordPress 'link to media file'). none: images only." }
12
12
  },
13
13
  "props": {
14
- "items": { "type": "array", "required": true, "items": { "type": "object", "fields": { "image": { "type": "object", "required": true, "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } } }, "caption": { "type": "string" } } }, "description": "The images in order (core/gallery inner images, Elementor gallery items, Divi gallery_ids)." },
15
- "heading": { "type": "string", "description": "Section heading above the images." },
16
- "closeLabel": { "type": "string", "description": "Text of the viewer's close button (ui-strings gallery.close)." },
17
- "tone": { "type": "select", "options": ["default", "muted", "inverse", "accent"], "description": "Section background." },
18
- "id": { "type": "string", "description": "Section id, unique per page: viewer ids derive from it (`<id>-image-<n>`)." }
14
+ "items": { "type": "array", "label": "Images", "required": true, "items": { "type": "object", "fields": { "image": { "type": "image", "label": "Image", "required": true, "description": "The image." }, "imageAlt": { "type": "string", "label": "Image description", "description": "What the image shows, for readers who cannot see it." }, "caption": { "type": "string", "label": "Caption", "description": "Caption under the enlarged image." } } }, "description": "The images in order (core/gallery inner images, Elementor gallery items, Divi gallery_ids)." },
15
+ "heading": { "type": "string", "label": "Heading", "description": "Section heading above the images." },
16
+ "closeLabel": { "type": "string", "content": false, "description": "Text of the viewer's close button (ui-strings gallery.close)." },
17
+ "tone": { "type": "select", "options": ["default", "muted", "inverse", "accent"], "content": false, "description": "Section background." },
18
+ "id": { "type": "string", "content": false, "description": "Section id, unique per page: viewer ids derive from it (`<id>-image-<n>`)." }
19
19
  },
20
20
  "slots": {},
21
21
  "js": "none",