ferst-core 0.7.4 → 0.7.5

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.
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { pageSlugs, uniqueSlug } from '../../lib/slug';
2
3
  interface Props {
3
4
  text: string;
4
5
  level?: number;
@@ -7,9 +8,11 @@ interface Props {
7
8
  }
8
9
  const { text, level = 2, align, gradient = false } = Astro.props;
9
10
  const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
11
+ // A readable, page-unique anchor so any heading can be linked to (#mass-times).
12
+ const id = uniqueSlug(text, pageSlugs(Astro.locals));
10
13
  ---
11
14
 
12
- <Tag class="b-heading" data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
15
+ <Tag class="b-heading" id={id} data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
13
16
 
14
17
  <style>
15
18
  /* By default a heading INHERITS the page/section content alignment (the
@@ -22,6 +25,7 @@ const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
22
25
  margin: 0;
23
26
  text-wrap: balance;
24
27
  white-space: pre-line; /* a title typed over several lines keeps its breaks */
28
+ scroll-margin-top: 6rem; /* a linked heading stops clear of the sticky header */
25
29
  line-height: 1.2;
26
30
  text-align: inherit;
27
31
  }
@@ -0,0 +1,139 @@
1
+ ---
2
+ /**
3
+ * Contacts — a compact directory of people or offices. Each card: the role as its title,
4
+ * the name, phone and email as tap targets (email with an optional subject), address and
5
+ * hours. A group can carry one shared note (e.g. office hours) instead of repeating it on
6
+ * every card. For one contact as a branded band use `contactCard`; for one contact's
7
+ * links in a row, `linkRow`.
8
+ */
9
+ import Heading from '../blocks/Heading.astro';
10
+ import Icon from '../Icon.astro';
11
+ import { inlineMarkdown, textWithBreaks } from '../../lib/inline-markdown';
12
+ import { mailtoHref, telHref } from '../../lib/links';
13
+
14
+ interface Contact { role: string; name?: string; phone?: string; email?: string; emailSubject?: string; address?: string; hours?: string }
15
+ interface Props {
16
+ title?: string;
17
+ intro?: string;
18
+ groups: Array<{ heading?: string; note?: string; contacts: Contact[] }>;
19
+ }
20
+ const { title, intro, groups = [] } = Astro.props;
21
+ ---
22
+
23
+ <section class="b-contacts">
24
+ {(title || intro) && (
25
+ <div class="b-contacts__head">
26
+ {title && <Heading text={title} level={2} />}
27
+ {intro && <p class="b-contacts__intro" set:html={inlineMarkdown(intro)} />}
28
+ </div>
29
+ )}
30
+ {groups.map((g) => (
31
+ <div class="b-contacts__group">
32
+ {(g.heading || g.note) && (
33
+ <div class="b-contacts__group-head">
34
+ {g.heading && <h3 class="b-contacts__group-title">{g.heading}</h3>}
35
+ {g.note && <p class="b-contacts__note" set:html={inlineMarkdown(g.note)} />}
36
+ </div>
37
+ )}
38
+ <ul class="b-contacts__grid">
39
+ {g.contacts.map((c) => (
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>}
43
+ <div class="b-contacts__lines">
44
+ {c.phone && <a class="b-contacts__line" href={telHref(c.phone)}><Icon name="phone" size={16} /><span>{c.phone}</span></a>}
45
+ {c.email && <a class="b-contacts__line" href={mailtoHref(c.email, c.emailSubject)}><Icon name="email" size={16} /><span>{c.email}</span></a>}
46
+ {c.address && <p class="b-contacts__line"><Icon name="map-pin" size={16} /><span set:html={textWithBreaks(c.address)} /></p>}
47
+ {c.hours && <p class="b-contacts__line"><Icon name="calendar" size={16} /><span set:html={textWithBreaks(c.hours)} /></p>}
48
+ </div>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </div>
53
+ ))}
54
+ </section>
55
+
56
+ <style>
57
+ .b-contacts {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 2rem;
61
+ width: 100%;
62
+ max-width: 72rem;
63
+ }
64
+ .b-contacts__head,
65
+ .b-contacts__group-head {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 0.4rem;
69
+ max-width: var(--max);
70
+ }
71
+ .b-contacts__intro,
72
+ .b-contacts__note {
73
+ margin: 0;
74
+ color: var(--muted);
75
+ line-height: 1.6;
76
+ }
77
+ .b-contacts__group {
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: 1rem;
81
+ }
82
+ .b-contacts__group-title {
83
+ margin: 0;
84
+ font-size: 1.15rem;
85
+ }
86
+ .b-contacts__grid {
87
+ list-style: none;
88
+ margin: 0;
89
+ padding: 0;
90
+ display: grid;
91
+ gap: 1rem;
92
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
93
+ }
94
+ .b-contacts__card {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 0.4rem;
98
+ padding: 1.1rem 1.25rem;
99
+ border: var(--card-border);
100
+ border-radius: var(--radius);
101
+ background: var(--bg-surface);
102
+ }
103
+ .b-contacts__role {
104
+ margin: 0;
105
+ font-size: 1.05rem;
106
+ }
107
+ .b-contacts__name {
108
+ margin: 0;
109
+ font-size: 0.9375rem;
110
+ color: var(--muted);
111
+ }
112
+ .b-contacts__lines {
113
+ display: grid;
114
+ gap: 0.35rem;
115
+ margin-top: 0.35rem;
116
+ }
117
+ .b-contacts__line {
118
+ display: flex;
119
+ align-items: flex-start;
120
+ gap: 0.5rem;
121
+ margin: 0;
122
+ color: var(--fg);
123
+ text-decoration: none;
124
+ font-size: 0.9375rem;
125
+ overflow-wrap: anywhere;
126
+ }
127
+ .b-contacts__line :global(svg) {
128
+ flex: none;
129
+ margin-top: 0.2em;
130
+ color: var(--gold);
131
+ }
132
+ a.b-contacts__line:hover span {
133
+ text-decoration: underline;
134
+ }
135
+ a.b-contacts__line:focus-visible {
136
+ outline: 2px solid var(--gold);
137
+ outline-offset: 2px;
138
+ }
139
+ </style>
@@ -1,152 +1,157 @@
1
- ---
2
- /**
3
- * Document — a policy or legal text as ONE block. Sections are numbered automatically,
4
- * a contents list links to each (beside the text, sticky, on wide screens), and an
5
- * optional "last updated" line sits at the top. Section bodies take prose formatting
6
- * (links, bold, lists); `items` add a list. Print-friendly: the contents list is
7
- * dropped on paper.
8
- */
9
- import { proseParts } from '../../lib/prose';
10
- import { inlineMarkdown } from '../../lib/inline-markdown';
11
- import { longDate } from '../../lib/documents';
12
-
13
- interface Section { title: string; body?: string; items?: string[] }
14
- interface Props {
15
- intro?: string;
16
- updated?: string;
17
- numbered?: boolean;
18
- toc?: boolean;
19
- sections: Section[];
20
- }
21
- const { intro, updated, numbered = true, toc = true, sections = [] } = Astro.props;
22
- const updatedLabel = longDate(updated);
23
- const showToc = toc && sections.length > 1;
24
- ---
25
-
26
- <section class="b-doc" data-toc={showToc ? 'true' : undefined}>
27
- {showToc && (
28
- <nav class="b-doc__toc" aria-label="Contents">
29
- <p class="b-doc__toc-title">Contents</p>
30
- <ol>
31
- {sections.map((s, i) => (
32
- <li><a href={`#clause-${i + 1}`}>{numbered && <span class="b-doc__toc-num">{i + 1}.</span>} {s.title}</a></li>
33
- ))}
34
- </ol>
35
- </nav>
36
- )}
37
- <div class="b-doc__body">
38
- {updatedLabel && <p class="b-doc__updated">Last updated {updatedLabel}</p>}
39
- {intro && <p class="b-doc__intro" set:html={inlineMarkdown(intro)} />}
40
- {sections.map((s, i) => (
41
- <section class="b-doc__clause" id={`clause-${i + 1}`}>
42
- <h2>{numbered && <span class="b-doc__num">{i + 1}.</span>} {s.title}</h2>
43
- {proseParts(s.body).map((part) =>
44
- part.kind === 'p' ? <p set:html={part.html} />
45
- : part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
46
- : <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
47
- )}
48
- {s.items && s.items.length > 0 && <ul>{s.items.map((x) => <li set:html={inlineMarkdown(x)} />)}</ul>}
49
- </section>
50
- ))}
51
- </div>
52
- </section>
53
-
54
- <style>
55
- .b-doc {
56
- display: grid;
57
- gap: 2rem;
58
- width: 100%;
59
- max-width: 64rem;
60
- }
61
- @media (min-width: 64em) {
62
- .b-doc[data-toc] {
63
- grid-template-columns: 15rem minmax(0, 1fr);
64
- align-items: start;
65
- }
66
- .b-doc__toc {
67
- position: sticky;
68
- top: 6rem;
69
- }
70
- }
71
- .b-doc__toc {
72
- border: var(--card-border);
73
- border-radius: var(--radius);
74
- background: var(--bg-surface);
75
- padding: 1rem 1.1rem;
76
- }
77
- .b-doc__toc-title {
78
- margin: 0 0 0.5rem;
79
- font-size: 0.75rem;
80
- font-weight: 600;
81
- letter-spacing: 0.08em;
82
- text-transform: uppercase;
83
- color: var(--muted);
84
- }
85
- .b-doc__toc ol {
86
- list-style: none;
87
- margin: 0;
88
- padding: 0;
89
- display: grid;
90
- gap: 0.35rem;
91
- font-size: 0.9375rem;
92
- }
93
- .b-doc__toc a {
94
- color: var(--fg);
95
- text-decoration: none;
96
- }
97
- .b-doc__toc a:hover {
98
- color: var(--gold);
99
- text-decoration: underline;
100
- }
101
- .b-doc__toc-num {
102
- color: var(--muted);
103
- font-variant-numeric: tabular-nums;
104
- }
105
- .b-doc__body {
106
- max-width: var(--max);
107
- display: flex;
108
- flex-direction: column;
109
- gap: 1.75rem;
110
- }
111
- .b-doc__updated {
112
- margin: 0;
113
- font-size: 0.875rem;
114
- color: var(--muted);
115
- }
116
- .b-doc__intro {
117
- margin: 0;
118
- font-size: 1.125rem;
119
- line-height: 1.6;
120
- color: var(--muted);
121
- }
122
- .b-doc__clause {
123
- scroll-margin-top: 6rem;
124
- }
125
- .b-doc__clause h2 {
126
- margin: 0 0 0.6rem;
127
- font-size: 1.25rem;
128
- }
129
- .b-doc__num {
130
- color: var(--gold);
131
- font-variant-numeric: tabular-nums;
132
- }
133
- .b-doc__clause p,
134
- .b-doc__clause ul,
135
- .b-doc__clause ol {
136
- margin: 0 0 0.75rem;
137
- line-height: 1.7;
138
- }
139
- .b-doc__clause ul,
140
- .b-doc__clause ol {
141
- padding-inline-start: 1.4em;
142
- }
143
- .b-doc__clause :global(a) {
144
- color: var(--accent);
145
- text-decoration: underline;
146
- text-underline-offset: 0.15em;
147
- }
148
- @media print {
149
- .b-doc__toc { display: none; }
150
- .b-doc[data-toc] { grid-template-columns: 1fr; }
151
- }
152
- </style>
1
+ ---
2
+ /**
3
+ * Document — a policy or legal text as ONE block. Sections are numbered automatically,
4
+ * a contents list links to each (beside the text, sticky, on wide screens), and an
5
+ * optional "last updated" line sits at the top. Section bodies take prose formatting
6
+ * (links, bold, lists); `items` add a list. Print-friendly: the contents list is
7
+ * dropped on paper.
8
+ */
9
+ import { proseParts } from '../../lib/prose';
10
+ import { inlineMarkdown } from '../../lib/inline-markdown';
11
+ import { longDate } from '../../lib/documents';
12
+ import { pageSlugs, uniqueSlug } from '../../lib/slug';
13
+
14
+ interface Section { title: string; body?: string; items?: string[] }
15
+ interface Props {
16
+ intro?: string;
17
+ updated?: string;
18
+ numbered?: boolean;
19
+ toc?: boolean;
20
+ sections: Section[];
21
+ }
22
+ const { intro, updated, numbered = true, toc = true, sections = [] } = Astro.props;
23
+ const updatedLabel = longDate(updated);
24
+ const showToc = toc && sections.length > 1;
25
+ // Readable anchors that survive renumbering (#payment); #clause-N stays on the heading.
26
+ const used = pageSlugs(Astro.locals);
27
+ const ids = sections.map((s) => uniqueSlug(s.title, used));
28
+ ---
29
+
30
+ <section class="b-doc" data-toc={showToc ? 'true' : undefined}>
31
+ {showToc && (
32
+ <nav class="b-doc__toc" aria-label="Contents">
33
+ <p class="b-doc__toc-title">Contents</p>
34
+ <ol>
35
+ {sections.map((s, i) => (
36
+ <li><a href={`#${ids[i]}`}>{numbered && <span class="b-doc__toc-num">{i + 1}.</span>} {s.title}</a></li>
37
+ ))}
38
+ </ol>
39
+ </nav>
40
+ )}
41
+ <div class="b-doc__body">
42
+ {updatedLabel && <p class="b-doc__updated">Last updated {updatedLabel}</p>}
43
+ {intro && <p class="b-doc__intro" set:html={inlineMarkdown(intro)} />}
44
+ {sections.map((s, i) => (
45
+ <section class="b-doc__clause" id={ids[i]}>
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
+ )}
52
+ {s.items && s.items.length > 0 && <ul>{s.items.map((x) => <li set:html={inlineMarkdown(x)} />)}</ul>}
53
+ </section>
54
+ ))}
55
+ </div>
56
+ </section>
57
+
58
+ <style>
59
+ .b-doc {
60
+ display: grid;
61
+ gap: 2rem;
62
+ width: 100%;
63
+ max-width: 64rem;
64
+ }
65
+ @media (min-width: 64em) {
66
+ .b-doc[data-toc] {
67
+ grid-template-columns: 15rem minmax(0, 1fr);
68
+ align-items: start;
69
+ }
70
+ .b-doc__toc {
71
+ position: sticky;
72
+ top: 6rem;
73
+ }
74
+ }
75
+ .b-doc__toc {
76
+ border: var(--card-border);
77
+ border-radius: var(--radius);
78
+ background: var(--bg-surface);
79
+ padding: 1rem 1.1rem;
80
+ }
81
+ .b-doc__toc-title {
82
+ margin: 0 0 0.5rem;
83
+ font-size: 0.75rem;
84
+ font-weight: 600;
85
+ letter-spacing: 0.08em;
86
+ text-transform: uppercase;
87
+ color: var(--muted);
88
+ }
89
+ .b-doc__toc ol {
90
+ list-style: none;
91
+ margin: 0;
92
+ padding: 0;
93
+ display: grid;
94
+ gap: 0.35rem;
95
+ font-size: 0.9375rem;
96
+ }
97
+ .b-doc__toc a {
98
+ color: var(--fg);
99
+ text-decoration: none;
100
+ }
101
+ .b-doc__toc a:hover {
102
+ color: var(--gold);
103
+ text-decoration: underline;
104
+ }
105
+ .b-doc__toc-num {
106
+ color: var(--muted);
107
+ font-variant-numeric: tabular-nums;
108
+ }
109
+ .b-doc__body {
110
+ max-width: var(--max);
111
+ display: flex;
112
+ flex-direction: column;
113
+ gap: 1.75rem;
114
+ }
115
+ .b-doc__updated {
116
+ margin: 0;
117
+ font-size: 0.875rem;
118
+ color: var(--muted);
119
+ }
120
+ .b-doc__intro {
121
+ margin: 0;
122
+ font-size: 1.125rem;
123
+ line-height: 1.6;
124
+ color: var(--muted);
125
+ }
126
+ .b-doc__clause {
127
+ scroll-margin-top: 6rem;
128
+ }
129
+ .b-doc__clause h2 {
130
+ scroll-margin-top: 6rem;
131
+ margin: 0 0 0.6rem;
132
+ font-size: 1.25rem;
133
+ }
134
+ .b-doc__num {
135
+ color: var(--gold);
136
+ font-variant-numeric: tabular-nums;
137
+ }
138
+ .b-doc__clause p,
139
+ .b-doc__clause ul,
140
+ .b-doc__clause ol {
141
+ margin: 0 0 0.75rem;
142
+ line-height: 1.7;
143
+ }
144
+ .b-doc__clause ul,
145
+ .b-doc__clause ol {
146
+ padding-inline-start: 1.4em;
147
+ }
148
+ .b-doc__clause :global(a) {
149
+ color: var(--accent);
150
+ text-decoration: underline;
151
+ text-underline-offset: 0.15em;
152
+ }
153
+ @media print {
154
+ .b-doc__toc { display: none; }
155
+ .b-doc[data-toc] { grid-template-columns: 1fr; }
156
+ }
157
+ </style>
@@ -0,0 +1,92 @@
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>
@@ -8,6 +8,7 @@
8
8
  import Heading from '../blocks/Heading.astro';
9
9
 
10
10
  import { inlineMarkdown } from '../../lib/inline-markdown';
11
+ import { filledColumns } from '../../lib/table';
11
12
  interface Props {
12
13
  title?: string;
13
14
  intro?: string;
@@ -15,6 +16,8 @@ interface Props {
15
16
  rows?: string[][];
16
17
  }
17
18
  const { title, intro, columns = [], rows = [] } = Astro.props;
19
+ // Columns empty in every row (an unused "Evening") are dropped, not shown blank.
20
+ const keep = filledColumns(columns, rows);
18
21
  ---
19
22
 
20
23
  <section class="b-table">
@@ -28,12 +31,12 @@ const { title, intro, columns = [], rows = [] } = Astro.props;
28
31
  <table class="b-table__table">
29
32
  {columns.length > 0 && (
30
33
  <thead>
31
- <tr>{columns.map((c) => <th scope="col">{c}</th>)}</tr>
34
+ <tr>{keep.map((i) => <th scope="col">{columns[i]}</th>)}</tr>
32
35
  </thead>
33
36
  )}
34
37
  <tbody>
35
38
  {rows.map((row) => (
36
- <tr>{row.map((cell) => <td set:html={inlineMarkdown(cell)} />)}</tr>
39
+ <tr>{(columns.length ? keep : row.map((_, i) => i)).map((i) => <td set:html={inlineMarkdown(row[i] ?? '')} />)}</tr>
37
40
  ))}
38
41
  </tbody>
39
42
  </table>
package/content/blocks.ts CHANGED
@@ -631,6 +631,50 @@ export const timetableBlock = z.object({
631
631
  .min(1),
632
632
  });
633
633
 
634
+ /** Contacts — a compact directory of people or offices: role as the card title, phone and
635
+ * email as tap targets (email with an optional subject), address and hours, and a shared
636
+ * note per group (e.g. office hours) instead of repeating it on every card. */
637
+ export const contactsBlock = z.object({
638
+ type: z.literal('contacts'),
639
+ enabled: z.boolean().default(true),
640
+ title: z.string().optional(),
641
+ intro: z.string().optional(),
642
+ groups: z
643
+ .array(
644
+ z.object({
645
+ heading: z.string().optional(),
646
+ note: z.string().optional(),
647
+ contacts: z
648
+ .array(
649
+ z.object({
650
+ role: z.string(),
651
+ name: z.string().optional(),
652
+ phone: z.string().optional(),
653
+ email: z.string().optional(),
654
+ emailSubject: z.string().optional(),
655
+ address: z.string().optional(),
656
+ hours: z.string().optional(),
657
+ })
658
+ )
659
+ .min(1),
660
+ })
661
+ )
662
+ .min(1),
663
+ });
664
+
665
+ /** MediaText — an image beside its text (start or end), stacking on a phone. The text takes
666
+ * prose formatting; the image keeps its shape (`ratio` crops to a set shape); optional CTA. */
667
+ export const mediaTextBlock = z.object({
668
+ type: z.literal('mediaText'),
669
+ enabled: z.boolean().default(true),
670
+ title: z.string().optional(),
671
+ text: z.string(),
672
+ image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }),
673
+ imageSide: z.enum(['start', 'end']).default('end'),
674
+ ratio: z.enum(['auto', '1/1', '4/3', '3/2', '16/9']).default('auto'),
675
+ cta: z.object({ label: z.string(), href: z.string() }).optional(),
676
+ });
677
+
634
678
  export type RecipeBlock =
635
679
  | z.infer<typeof heroBlock>
636
680
  | z.infer<typeof featureCardsBlock>
@@ -661,7 +705,9 @@ export type RecipeBlock =
661
705
  | z.infer<typeof documentsBlock>
662
706
  | z.infer<typeof linkRowBlock>
663
707
  | z.infer<typeof documentBlock>
664
- | z.infer<typeof timetableBlock>;
708
+ | z.infer<typeof timetableBlock>
709
+ | z.infer<typeof contactsBlock>
710
+ | z.infer<typeof mediaTextBlock>;
665
711
 
666
712
  /* ── Level-1 layout / container primitives ─────────────────────────────────
667
713
  * These hold child blocks, so the schema is **recursive** (a container's
@@ -777,6 +823,8 @@ export const blockSchema = z.discriminatedUnion('type', [
777
823
  linkRowBlock,
778
824
  documentBlock,
779
825
  timetableBlock,
826
+ contactsBlock,
827
+ mediaTextBlock,
780
828
  sectionBlock,
781
829
  gridBlock,
782
830
  stackBlock,