ferst-core 0.7.5 → 0.7.7

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 (36) hide show
  1. package/bin/ferst-modules.mjs +91 -0
  2. package/cms/config.template.yml +13 -0
  3. package/components/BlockRenderer.astro +2 -0
  4. package/components/ProseParts.astro +26 -0
  5. package/components/SiteFooter.astro +52 -1
  6. package/components/SiteHeader.astro +1246 -1243
  7. package/components/blocks/Prose.astro +12 -17
  8. package/components/recipes/Accordion.astro +3 -7
  9. package/components/recipes/Contacts.astro +3 -3
  10. package/components/recipes/Document.astro +7 -11
  11. package/components/recipes/Documents.astro +2 -2
  12. package/components/recipes/Form.astro +30 -0
  13. package/components/recipes/MediaText.astro +88 -92
  14. package/components/recipes/Steps.astro +121 -120
  15. package/components/recipes/Table.astro +3 -2
  16. package/components/recipes/Video.astro +1 -1
  17. package/content/blocks.ts +917 -887
  18. package/content/schemas.ts +5 -0
  19. package/fonts/OFL.txt +287 -0
  20. package/fonts/inter-latin-400-normal.woff2 +0 -0
  21. package/fonts/inter-latin-500-normal.woff2 +0 -0
  22. package/fonts/inter-latin-600-normal.woff2 +0 -0
  23. package/fonts/playfair-display-latin-700-normal.woff2 +0 -0
  24. package/fonts/poppins-latin-300-normal.woff2 +0 -0
  25. package/fonts/poppins-latin-400-normal.woff2 +0 -0
  26. package/fonts/poppins-latin-500-normal.woff2 +0 -0
  27. package/fonts/poppins-latin-600-normal.woff2 +0 -0
  28. package/fonts/poppins-latin-700-normal.woff2 +0 -0
  29. package/integration.mjs +41 -1
  30. package/layouts/Base.astro +1 -0
  31. package/lib/forms.ts +57 -0
  32. package/lib/icons.ts +10 -0
  33. package/lib/links.ts +24 -22
  34. package/lib/nav.ts +12 -0
  35. package/lib/siteSettings.ts +2 -1
  36. package/package.json +5 -3
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { proseParts } from '../../lib/prose';
2
+ import ProseParts from '../ProseParts.astro';
3
3
  interface Props {
4
4
  text: string;
5
5
  variant?: 'body' | 'lead';
@@ -7,40 +7,35 @@ interface Props {
7
7
  const { text, variant = 'body' } = Astro.props;
8
8
  // Blank lines separate paragraphs; `- ` / `1. ` lines make lists; links, bold and
9
9
  // italic are rendered safely (lib/prose.ts → inlineMarkdown).
10
- const parts = proseParts(text);
11
10
  ---
12
11
 
13
12
  <div class="b-prose" data-variant={variant === 'lead' ? 'lead' : undefined}>
14
- {parts.map((part) =>
15
- part.kind === 'p' ? <p set:html={part.html} />
16
- : part.kind === 'ul' ? <ul>{part.items.map((i) => <li set:html={i} />)}</ul>
17
- : <ol>{part.items.map((i) => <li set:html={i} />)}</ol>
18
- )}
13
+ <ProseParts text={text} />
19
14
  </div>
20
15
 
21
16
  <style>
22
17
  .b-prose {
23
18
  color: var(--fg);
24
19
  }
25
- .b-prose p,
26
- .b-prose ul,
27
- .b-prose ol {
20
+ .b-prose :global(p),
21
+ .b-prose :global(ul),
22
+ .b-prose :global(ol) {
28
23
  margin: 0 0 1rem;
29
24
  line-height: 1.7;
30
25
  max-width: var(--max);
31
26
  }
32
- .b-prose ul,
33
- .b-prose ol {
27
+ .b-prose :global(ul),
28
+ .b-prose :global(ol) {
34
29
  padding-inline-start: 1.4em;
35
30
  }
36
- .b-prose li + li {
31
+ .b-prose :global(li + li) {
37
32
  margin-top: 0.25rem;
38
33
  }
39
- .b-prose > :last-child {
34
+ .b-prose > :global(:last-child) {
40
35
  margin-bottom: 0;
41
36
  }
42
37
  /* The page's introduction: larger, quieter, a narrower measure. */
43
- .b-prose[data-variant='lead'] p {
38
+ .b-prose[data-variant='lead'] :global(p) {
44
39
  font-size: clamp(1.125rem, 1.6vw, 1.3125rem);
45
40
  line-height: 1.6;
46
41
  color: var(--muted);
@@ -55,10 +50,10 @@ const parts = proseParts(text);
55
50
  section is centred (or end-aligned) re-anchor it so the text reads as
56
51
  deliberately centred, not centred inside a left column. Text-align itself
57
52
  is inherited from the content-align cascade. */
58
- :global([data-content-align='center']) .b-prose > * {
53
+ :global([data-content-align='center']) .b-prose > :global(*) {
59
54
  margin-inline: auto;
60
55
  }
61
- :global([data-content-align='end']) .b-prose > * {
56
+ :global([data-content-align='end']) .b-prose > :global(*) {
62
57
  margin-inline-start: auto;
63
58
  }
64
59
  </style>
@@ -4,7 +4,7 @@
4
4
  * Native <details>/<summary> — accessible, interactive, zero JavaScript. */
5
5
  import Heading from '../blocks/Heading.astro';
6
6
 
7
- import { proseParts } from '../../lib/prose';
7
+ import ProseParts from '../ProseParts.astro';
8
8
  interface Props {
9
9
  title?: string;
10
10
  items?: { heading: string; body: string }[];
@@ -19,11 +19,7 @@ const { title, items = [] } = Astro.props;
19
19
  <details class="b-accordion__item">
20
20
  <summary class="b-accordion__head">{item.heading}</summary>
21
21
  <div class="b-accordion__body">
22
- {proseParts(item.body).map((part) =>
23
- part.kind === 'p' ? <p set:html={part.html} />
24
- : part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
25
- : <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
26
- )}
22
+ <ProseParts text={item.body} />
27
23
  </div>
28
24
  </details>
29
25
  ))}
@@ -85,7 +81,7 @@ const { title, items = [] } = Astro.props;
85
81
  color: var(--muted);
86
82
  line-height: 1.6;
87
83
  }
88
- .b-accordion__body p {
84
+ .b-accordion__body :global(p) {
89
85
  margin: 0;
90
86
  }
91
87
  </style>
@@ -11,7 +11,7 @@ import Icon from '../Icon.astro';
11
11
  import { inlineMarkdown, textWithBreaks } from '../../lib/inline-markdown';
12
12
  import { mailtoHref, telHref } from '../../lib/links';
13
13
 
14
- interface Contact { role: string; name?: string; phone?: string; email?: string; emailSubject?: string; address?: string; hours?: string }
14
+ interface Contact { role?: string; name?: string; phone?: string; email?: string; emailSubject?: string; address?: string; hours?: string }
15
15
  interface Props {
16
16
  title?: string;
17
17
  intro?: string;
@@ -38,8 +38,8 @@ const { title, intro, groups = [] } = Astro.props;
38
38
  <ul class="b-contacts__grid">
39
39
  {g.contacts.map((c) => (
40
40
  <li class="b-contacts__card">
41
- <h3 class="b-contacts__role">{c.role}</h3>
42
- {c.name && <p class="b-contacts__name">{c.name}</p>}
41
+ <h3 class="b-contacts__role">{c.role ?? c.name}</h3>
42
+ {c.role && c.name && <p class="b-contacts__name">{c.name}</p>}
43
43
  <div class="b-contacts__lines">
44
44
  {c.phone && <a class="b-contacts__line" href={telHref(c.phone)}><Icon name="phone" size={16} /><span>{c.phone}</span></a>}
45
45
  {c.email && <a class="b-contacts__line" href={mailtoHref(c.email, c.emailSubject)}><Icon name="email" size={16} /><span>{c.email}</span></a>}
@@ -6,7 +6,7 @@
6
6
  * (links, bold, lists); `items` add a list. Print-friendly: the contents list is
7
7
  * dropped on paper.
8
8
  */
9
- import { proseParts } from '../../lib/prose';
9
+ import ProseParts from '../ProseParts.astro';
10
10
  import { inlineMarkdown } from '../../lib/inline-markdown';
11
11
  import { longDate } from '../../lib/documents';
12
12
  import { pageSlugs, uniqueSlug } from '../../lib/slug';
@@ -44,11 +44,7 @@ const ids = sections.map((s) => uniqueSlug(s.title, used));
44
44
  {sections.map((s, i) => (
45
45
  <section class="b-doc__clause" id={ids[i]}>
46
46
  <h2 id={`clause-${i + 1}`}>{numbered && <span class="b-doc__num">{i + 1}.</span>} {s.title}</h2>
47
- {proseParts(s.body).map((part) =>
48
- part.kind === 'p' ? <p set:html={part.html} />
49
- : part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
50
- : <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
51
- )}
47
+ <ProseParts text={s.body} />
52
48
  {s.items && s.items.length > 0 && <ul>{s.items.map((x) => <li set:html={inlineMarkdown(x)} />)}</ul>}
53
49
  </section>
54
50
  ))}
@@ -135,14 +131,14 @@ const ids = sections.map((s) => uniqueSlug(s.title, used));
135
131
  color: var(--gold);
136
132
  font-variant-numeric: tabular-nums;
137
133
  }
138
- .b-doc__clause p,
139
- .b-doc__clause ul,
140
- .b-doc__clause ol {
134
+ .b-doc__clause :global(p),
135
+ .b-doc__clause :global(ul),
136
+ .b-doc__clause :global(ol) {
141
137
  margin: 0 0 0.75rem;
142
138
  line-height: 1.7;
143
139
  }
144
- .b-doc__clause ul,
145
- .b-doc__clause ol {
140
+ .b-doc__clause :global(ul),
141
+ .b-doc__clause :global(ol) {
146
142
  padding-inline-start: 1.4em;
147
143
  }
148
144
  .b-doc__clause :global(a) {
@@ -8,7 +8,7 @@
8
8
  import Heading from '../blocks/Heading.astro';
9
9
  import Icon from '../Icon.astro';
10
10
  import { documentGroups, type DocumentItem } from '../../lib/documents';
11
-
11
+ import { inlineMarkdown } from '../../lib/inline-markdown';
12
12
  interface Props {
13
13
  title?: string;
14
14
  intro?: string;
@@ -23,7 +23,7 @@ const groups = documentGroups(items, groupByYear);
23
23
  {(title || intro) && (
24
24
  <div class="b-docs__head">
25
25
  {title && <Heading text={title} level={2} />}
26
- {intro && <p class="b-docs__intro">{intro}</p>}
26
+ {intro && <p class="b-docs__intro" set:html={inlineMarkdown(intro)} />}
27
27
  </div>
28
28
  )}
29
29
  {items.length === 0 ? (
@@ -0,0 +1,30 @@
1
+ ---
2
+ /**
3
+ * Form — one of the organisation's forms from the Ferst portal, placed by id. Reads the
4
+ * form's fields, button and submit address from the platform at build time (lib/forms)
5
+ * and renders it with the ContactForm recipe, so it looks like every other form and a
6
+ * change made in the portal reaches the site on its next build.
7
+ */
8
+ import ContactForm from './ContactForm.astro';
9
+ import { loadFormDefinition } from '../../lib/forms';
10
+ import { resolveGitBase } from '../../bin/cms-config.mjs';
11
+
12
+ interface Props {
13
+ form: string;
14
+ title?: string;
15
+ intro?: string;
16
+ note?: string;
17
+ }
18
+ const { form, title, intro, note } = Astro.props;
19
+ const def = await loadFormDefinition(form, { base: resolveGitBase() });
20
+ ---
21
+
22
+ <ContactForm
23
+ title={title ?? def.name}
24
+ intro={intro}
25
+ action={def.action}
26
+ method="post"
27
+ submitLabel={def.submitLabel}
28
+ note={note}
29
+ fields={def.fields}
30
+ />
@@ -1,92 +1,88 @@
1
- ---
2
- /**
3
- * MediaText — an image beside its text, on the start or end side; stacks (text, then image)
4
- * on a phone. The text takes prose formatting (paragraphs, lists, links); the image keeps
5
- * its own shape unless `ratio` sets one (cropped to fill). An optional button follows the
6
- * text.
7
- */
8
- import Heading from '../blocks/Heading.astro';
9
- import Button from '../Button.astro';
10
- import { proseParts } from '../../lib/prose';
11
-
12
- interface Props {
13
- title?: string;
14
- text: string;
15
- image: { src: string; alt?: string; caption?: string };
16
- imageSide?: 'start' | 'end';
17
- ratio?: 'auto' | '1/1' | '4/3' | '3/2' | '16/9';
18
- cta?: { label: string; href: string };
19
- }
20
- const { title, text, image, imageSide = 'end', ratio = 'auto', cta } = Astro.props;
21
- ---
22
-
23
- <section class="b-mediatext" data-side={imageSide}>
24
- <div class="b-mediatext__text">
25
- {title && <Heading text={title} level={2} />}
26
- {proseParts(text).map((part) =>
27
- part.kind === 'p' ? <p set:html={part.html} />
28
- : part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
29
- : <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
30
- )}
31
- {cta && <div class="b-mediatext__cta"><Button href={cta.href} variant="primary">{cta.label}</Button></div>}
32
- </div>
33
- <figure class="b-mediatext__media">
34
- <img src={image.src} alt={image.alt ?? ''} loading="lazy" decoding="async" style={ratio !== 'auto' ? `aspect-ratio: ${ratio}; object-fit: cover` : undefined} />
35
- {image.caption && <figcaption>{image.caption}</figcaption>}
36
- </figure>
37
- </section>
38
-
39
- <style>
40
- .b-mediatext {
41
- display: grid;
42
- gap: clamp(1.5rem, 4vw, 3rem);
43
- align-items: center;
44
- width: 100%;
45
- max-width: 72rem;
46
- }
47
- @media (min-width: 48em) {
48
- .b-mediatext {
49
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
50
- }
51
- .b-mediatext[data-side='start'] .b-mediatext__media {
52
- order: -1;
53
- }
54
- }
55
- .b-mediatext__text {
56
- display: flex;
57
- flex-direction: column;
58
- gap: 0.9rem;
59
- }
60
- .b-mediatext__text p,
61
- .b-mediatext__text ul,
62
- .b-mediatext__text ol {
63
- margin: 0;
64
- line-height: 1.7;
65
- }
66
- .b-mediatext__text ul,
67
- .b-mediatext__text ol {
68
- padding-inline-start: 1.4em;
69
- }
70
- .b-mediatext__text :global(a:not(.btn)) {
71
- color: var(--accent);
72
- text-decoration: underline;
73
- text-underline-offset: 0.15em;
74
- }
75
- .b-mediatext__cta {
76
- margin-top: 0.4rem;
77
- }
78
- .b-mediatext__media {
79
- margin: 0;
80
- }
81
- .b-mediatext__media img {
82
- display: block;
83
- width: 100%;
84
- height: auto;
85
- border-radius: var(--radius);
86
- }
87
- .b-mediatext__media figcaption {
88
- margin-top: 0.5rem;
89
- font-size: 0.875rem;
90
- color: var(--muted);
91
- }
92
- </style>
1
+ ---
2
+ /**
3
+ * MediaText — an image beside its text, on the start or end side; stacks (text, then image)
4
+ * on a phone. The text takes prose formatting (paragraphs, lists, links); the image keeps
5
+ * its own shape unless `ratio` sets one (cropped to fill). An optional button follows the
6
+ * text.
7
+ */
8
+ import Heading from '../blocks/Heading.astro';
9
+ import Button from '../Button.astro';
10
+ import ProseParts from '../ProseParts.astro';
11
+
12
+ interface Props {
13
+ title?: string;
14
+ text: string;
15
+ image: { src: string; alt?: string; caption?: string };
16
+ imageSide?: 'start' | 'end';
17
+ ratio?: 'auto' | '1/1' | '4/3' | '3/2' | '16/9';
18
+ cta?: { label: string; href: string };
19
+ }
20
+ const { title, text, image, imageSide = 'end', ratio = 'auto', cta } = Astro.props;
21
+ ---
22
+
23
+ <section class="b-mediatext" data-side={imageSide}>
24
+ <div class="b-mediatext__text">
25
+ {title && <Heading text={title} level={2} />}
26
+ <ProseParts text={text} />
27
+ {cta && <div class="b-mediatext__cta"><Button href={cta.href} variant="primary">{cta.label}</Button></div>}
28
+ </div>
29
+ <figure class="b-mediatext__media">
30
+ <img src={image.src} alt={image.alt ?? ''} loading="lazy" decoding="async" style={ratio !== 'auto' ? `aspect-ratio: ${ratio}; object-fit: cover` : undefined} />
31
+ {image.caption && <figcaption>{image.caption}</figcaption>}
32
+ </figure>
33
+ </section>
34
+
35
+ <style>
36
+ .b-mediatext {
37
+ display: grid;
38
+ gap: clamp(1.5rem, 4vw, 3rem);
39
+ align-items: center;
40
+ width: 100%;
41
+ max-width: 72rem;
42
+ }
43
+ @media (min-width: 48em) {
44
+ .b-mediatext {
45
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
46
+ }
47
+ .b-mediatext[data-side='start'] .b-mediatext__media {
48
+ order: -1;
49
+ }
50
+ }
51
+ .b-mediatext__text {
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 0.9rem;
55
+ }
56
+ .b-mediatext__text :global(p),
57
+ .b-mediatext__text :global(ul),
58
+ .b-mediatext__text :global(ol) {
59
+ margin: 0;
60
+ line-height: 1.7;
61
+ }
62
+ .b-mediatext__text :global(ul),
63
+ .b-mediatext__text :global(ol) {
64
+ padding-inline-start: 1.4em;
65
+ }
66
+ .b-mediatext__text :global(a:not(.btn)) {
67
+ color: var(--accent);
68
+ text-decoration: underline;
69
+ text-underline-offset: 0.15em;
70
+ }
71
+ .b-mediatext__cta {
72
+ margin-top: 0.4rem;
73
+ }
74
+ .b-mediatext__media {
75
+ margin: 0;
76
+ }
77
+ .b-mediatext__media img {
78
+ display: block;
79
+ width: 100%;
80
+ height: auto;
81
+ border-radius: var(--radius);
82
+ }
83
+ .b-mediatext__media figcaption {
84
+ margin-top: 0.5rem;
85
+ font-size: 0.875rem;
86
+ color: var(--muted);
87
+ }
88
+ </style>
@@ -1,120 +1,121 @@
1
- ---
2
- /** Steps — a Level-2 recipe: a numbered process. `horizontal` lays connected
3
- * cards in a row; `vertical` a stacked timeline. Composes Heading + Icon. */
4
- import Heading from '../blocks/Heading.astro';
5
- import Icon from '../Icon.astro';
6
- import { isIconKey } from '../../lib/icons';
7
-
8
- interface Item { title: string; text?: string; icon?: string }
9
- interface Props {
10
- title?: string;
11
- intro?: string;
12
- layout?: 'horizontal' | 'vertical';
13
- items?: Item[];
14
- }
15
- const { title, intro, layout = 'horizontal', items = [] } = Astro.props;
16
- ---
17
-
18
- <section class="b-steps" data-layout={layout}>
19
- {(title || intro) && (
20
- <div class="b-steps__head">
21
- {title && <Heading text={title} level={2} align="center" />}
22
- {intro && <p class="b-steps__intro">{intro}</p>}
23
- </div>
24
- )}
25
- <ol class="b-steps__list">
26
- {items.map((item, i) => (
27
- <li class="b-steps__item">
28
- <span class="b-steps__marker" aria-hidden="true">
29
- {item.icon && isIconKey(item.icon) ? <Icon name={item.icon} size={20} /> : i + 1}
30
- </span>
31
- <div class="b-steps__body">
32
- <h3 class="b-steps__title">{item.title}</h3>
33
- {item.text && <p class="b-steps__text">{item.text}</p>}
34
- </div>
35
- </li>
36
- ))}
37
- </ol>
38
- </section>
39
-
40
- <style>
41
- .b-steps {
42
- display: flex;
43
- flex-direction: column;
44
- gap: 2rem;
45
- max-width: 72rem;
46
- margin-inline: auto;
47
- padding-inline: 1rem;
48
- }
49
- .b-steps__head {
50
- max-width: var(--max);
51
- margin-inline: auto;
52
- text-align: center;
53
- display: flex;
54
- flex-direction: column;
55
- gap: 0.75rem;
56
- }
57
- .b-steps__intro { margin: 0; color: var(--muted); line-height: 1.6; }
58
- .b-steps__list {
59
- list-style: none;
60
- margin: 0;
61
- padding: 0;
62
- display: grid;
63
- gap: 1.5rem;
64
- }
65
- /* Base (and mobile): marker left, body right. */
66
- .b-steps__item {
67
- display: flex;
68
- gap: 1rem;
69
- align-items: flex-start;
70
- }
71
- .b-steps__marker {
72
- flex-shrink: 0;
73
- width: 2.5rem;
74
- height: 2.5rem;
75
- display: inline-flex;
76
- align-items: center;
77
- justify-content: center;
78
- border-radius: var(--radius-pill);
79
- background: var(--gold);
80
- color: var(--text-light);
81
- font-family: var(--font-heading);
82
- font-weight: 700;
83
- font-size: 1.05rem;
84
- }
85
- .b-steps__title { margin: 0 0 0.35rem; font-size: 1.1rem; color: var(--fg); }
86
- .b-steps__text { margin: 0; color: var(--muted); line-height: 1.6; }
87
-
88
- /* Vertical: a centred, stacked timeline. */
89
- .b-steps[data-layout='vertical'] .b-steps__list { max-width: 40rem; margin-inline: auto; }
90
-
91
- /* Horizontal (≥48em): connected cards in a row, marker over centred body. */
92
- @media (min-width: 48em) {
93
- .b-steps[data-layout='horizontal'] .b-steps__list {
94
- grid-auto-flow: column;
95
- grid-auto-columns: 1fr;
96
- gap: 1rem;
97
- }
98
- .b-steps[data-layout='horizontal'] .b-steps__item {
99
- flex-direction: column;
100
- align-items: center;
101
- text-align: center;
102
- gap: 0.85rem;
103
- position: relative;
104
- }
105
- /* Connector from this marker's right edge to the next marker's left edge.
106
- The +1.5rem / -2rem account for the 2.5rem marker AND the 1rem grid gap,
107
- so the line sits centred between markers (not falling short). */
108
- .b-steps[data-layout='horizontal'] .b-steps__item:not(:last-child)::after {
109
- content: '';
110
- position: absolute;
111
- top: calc(1.25rem - 1px);
112
- left: calc(50% + 1.5rem);
113
- width: calc(100% - 2rem);
114
- height: 2px;
115
- background: var(--border);
116
- z-index: 0;
117
- }
118
- .b-steps[data-layout='horizontal'] .b-steps__marker { position: relative; z-index: 1; }
119
- }
120
- </style>
1
+ ---
2
+ /** Steps — a Level-2 recipe: a numbered process. `horizontal` lays connected
3
+ * cards in a row; `vertical` a stacked timeline. Composes Heading + Icon. */
4
+ import Heading from '../blocks/Heading.astro';
5
+ import Icon from '../Icon.astro';
6
+ import { isIconKey } from '../../lib/icons';
7
+
8
+ import { inlineMarkdown } from '../../lib/inline-markdown';
9
+ interface Item { title: string; text?: string; icon?: string }
10
+ interface Props {
11
+ title?: string;
12
+ intro?: string;
13
+ layout?: 'horizontal' | 'vertical';
14
+ items?: Item[];
15
+ }
16
+ const { title, intro, layout = 'horizontal', items = [] } = Astro.props;
17
+ ---
18
+
19
+ <section class="b-steps" data-layout={layout}>
20
+ {(title || intro) && (
21
+ <div class="b-steps__head">
22
+ {title && <Heading text={title} level={2} align="center" />}
23
+ {intro && <p class="b-steps__intro" set:html={inlineMarkdown(intro)} />}
24
+ </div>
25
+ )}
26
+ <ol class="b-steps__list">
27
+ {items.map((item, i) => (
28
+ <li class="b-steps__item">
29
+ <span class="b-steps__marker" aria-hidden="true">
30
+ {item.icon && isIconKey(item.icon) ? <Icon name={item.icon} size={20} /> : i + 1}
31
+ </span>
32
+ <div class="b-steps__body">
33
+ <h3 class="b-steps__title">{item.title}</h3>
34
+ {item.text && <p class="b-steps__text">{item.text}</p>}
35
+ </div>
36
+ </li>
37
+ ))}
38
+ </ol>
39
+ </section>
40
+
41
+ <style>
42
+ .b-steps {
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: 2rem;
46
+ max-width: 72rem;
47
+ margin-inline: auto;
48
+ padding-inline: 1rem;
49
+ }
50
+ .b-steps__head {
51
+ max-width: var(--max);
52
+ margin-inline: auto;
53
+ text-align: center;
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 0.75rem;
57
+ }
58
+ .b-steps__intro { margin: 0; color: var(--muted); line-height: 1.6; }
59
+ .b-steps__list {
60
+ list-style: none;
61
+ margin: 0;
62
+ padding: 0;
63
+ display: grid;
64
+ gap: 1.5rem;
65
+ }
66
+ /* Base (and mobile): marker left, body right. */
67
+ .b-steps__item {
68
+ display: flex;
69
+ gap: 1rem;
70
+ align-items: flex-start;
71
+ }
72
+ .b-steps__marker {
73
+ flex-shrink: 0;
74
+ width: 2.5rem;
75
+ height: 2.5rem;
76
+ display: inline-flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ border-radius: var(--radius-pill);
80
+ background: var(--gold);
81
+ color: var(--text-light);
82
+ font-family: var(--font-heading);
83
+ font-weight: 700;
84
+ font-size: 1.05rem;
85
+ }
86
+ .b-steps__title { margin: 0 0 0.35rem; font-size: 1.1rem; color: var(--fg); }
87
+ .b-steps__text { margin: 0; color: var(--muted); line-height: 1.6; }
88
+
89
+ /* Vertical: a centred, stacked timeline. */
90
+ .b-steps[data-layout='vertical'] .b-steps__list { max-width: 40rem; margin-inline: auto; }
91
+
92
+ /* Horizontal (≥48em): connected cards in a row, marker over centred body. */
93
+ @media (min-width: 48em) {
94
+ .b-steps[data-layout='horizontal'] .b-steps__list {
95
+ grid-auto-flow: column;
96
+ grid-auto-columns: 1fr;
97
+ gap: 1rem;
98
+ }
99
+ .b-steps[data-layout='horizontal'] .b-steps__item {
100
+ flex-direction: column;
101
+ align-items: center;
102
+ text-align: center;
103
+ gap: 0.85rem;
104
+ position: relative;
105
+ }
106
+ /* Connector from this marker's right edge to the next marker's left edge.
107
+ The +1.5rem / -2rem account for the 2.5rem marker AND the 1rem grid gap,
108
+ so the line sits centred between markers (not falling short). */
109
+ .b-steps[data-layout='horizontal'] .b-steps__item:not(:last-child)::after {
110
+ content: '';
111
+ position: absolute;
112
+ top: calc(1.25rem - 1px);
113
+ left: calc(50% + 1.5rem);
114
+ width: calc(100% - 2rem);
115
+ height: 2px;
116
+ background: var(--border);
117
+ z-index: 0;
118
+ }
119
+ .b-steps[data-layout='horizontal'] .b-steps__marker { position: relative; z-index: 1; }
120
+ }
121
+ </style>