ferst-core 0.6.2 → 0.7.2
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.
- package/bin/ferst-update.mjs +73 -2
- package/components/BlockRenderer.astro +107 -103
- package/components/blocks/Prose.astro +56 -39
- package/components/recipes/ContactCard.astro +172 -170
- package/components/recipes/Documents.astro +146 -0
- package/components/recipes/Hero.astro +447 -191
- package/components/recipes/Tiles.astro +285 -252
- package/components/recipes/Video.astro +157 -0
- package/content/blocks.ts +775 -719
- package/content/cookies.ts +112 -103
- package/examples/primitives.ts +28 -28
- package/examples/recipes.ts +37 -32
- package/lib/documents.ts +62 -0
- package/lib/inline-markdown.ts +65 -52
- package/lib/prose.ts +29 -0
- package/lib/video.ts +75 -0
- package/package.json +1 -1
|
@@ -1,170 +1,172 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* ContactCard — a Level-2 recipe: a rich, branded "get in touch" band. A display-
|
|
4
|
-
* type title + subtitle on the left, the contact channels as tappable tiles on the
|
|
5
|
-
* right, and a closing note across the bottom. For a contact or find-us page — the
|
|
6
|
-
* polished alternative to a plain notice box.
|
|
7
|
-
*
|
|
8
|
-
* Fully THEME-ADAPTIVE (composes in light AND dark): the panel is `--bg-section`
|
|
9
|
-
* with a soft `--gold` corner wash for brand presence, text is `--fg`/`--accent`/
|
|
10
|
-
* `--muted`, tiles are `--bg-surface` recesses, icons + hovers use `--gold`. Every
|
|
11
|
-
* one of those tokens flips per theme, so the band re-composes correctly in each
|
|
12
|
-
* mode (never the old always-dark band) and a CMS colour change recolours it.
|
|
13
|
-
* Contrast-safe: brand gold is used only decoratively (wash, rule, icons, hovers),
|
|
14
|
-
* never as body/label text — those use the ink/text tokens that meet AA on the
|
|
15
|
-
* panel in both themes. Whole tile links when `href` is set.
|
|
16
|
-
*/
|
|
17
|
-
import Icon from '../Icon.astro';
|
|
18
|
-
import { isIconKey } from '../../lib/icons';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
border:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
font-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
.b-
|
|
161
|
-
.b-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ContactCard — a Level-2 recipe: a rich, branded "get in touch" band. A display-
|
|
4
|
+
* type title + subtitle on the left, the contact channels as tappable tiles on the
|
|
5
|
+
* right, and a closing note across the bottom. For a contact or find-us page — the
|
|
6
|
+
* polished alternative to a plain notice box.
|
|
7
|
+
*
|
|
8
|
+
* Fully THEME-ADAPTIVE (composes in light AND dark): the panel is `--bg-section`
|
|
9
|
+
* with a soft `--gold` corner wash for brand presence, text is `--fg`/`--accent`/
|
|
10
|
+
* `--muted`, tiles are `--bg-surface` recesses, icons + hovers use `--gold`. Every
|
|
11
|
+
* one of those tokens flips per theme, so the band re-composes correctly in each
|
|
12
|
+
* mode (never the old always-dark band) and a CMS colour change recolours it.
|
|
13
|
+
* Contrast-safe: brand gold is used only decoratively (wash, rule, icons, hovers),
|
|
14
|
+
* never as body/label text — those use the ink/text tokens that meet AA on the
|
|
15
|
+
* panel in both themes. Whole tile links when `href` is set.
|
|
16
|
+
*/
|
|
17
|
+
import Icon from '../Icon.astro';
|
|
18
|
+
import { isIconKey } from '../../lib/icons';
|
|
19
|
+
import { inlineMarkdown, textWithBreaks } from '../../lib/inline-markdown';
|
|
20
|
+
|
|
21
|
+
interface Item {
|
|
22
|
+
icon?: string;
|
|
23
|
+
value: string;
|
|
24
|
+
href?: string;
|
|
25
|
+
}
|
|
26
|
+
interface Props {
|
|
27
|
+
eyebrow?: string;
|
|
28
|
+
title: string;
|
|
29
|
+
subtitle?: string;
|
|
30
|
+
note?: string;
|
|
31
|
+
items?: Item[];
|
|
32
|
+
}
|
|
33
|
+
const { eyebrow, title, subtitle, note, items = [] } = Astro.props;
|
|
34
|
+
const rel = (href?: string) => (href && /^https?:\/\//.test(href) ? 'noopener' : undefined);
|
|
35
|
+
const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank' : undefined);
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<section class="b-contact">
|
|
39
|
+
<div class="b-contact__panel">
|
|
40
|
+
<div class="b-contact__head">
|
|
41
|
+
{eyebrow && <p class="b-contact__eyebrow">{eyebrow}</p>}
|
|
42
|
+
<h2 class="b-contact__title">{title}</h2>
|
|
43
|
+
{subtitle && <p class="b-contact__subtitle" set:html={inlineMarkdown(subtitle)} />}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{items.length > 0 && (
|
|
47
|
+
<ul class="b-contact__items">
|
|
48
|
+
{items.map((item) => {
|
|
49
|
+
const inner = (
|
|
50
|
+
<>
|
|
51
|
+
{item.icon && isIconKey(item.icon) && (
|
|
52
|
+
<span class="b-contact__icon" aria-hidden="true"><Icon name={item.icon} size={18} /></span>
|
|
53
|
+
)}
|
|
54
|
+
{/* A linked item holds plain text (no nested links); an unlinked one may carry links. */}
|
|
55
|
+
<span class="b-contact__value" set:html={item.href ? textWithBreaks(item.value) : inlineMarkdown(item.value)} />
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
return (
|
|
59
|
+
<li>
|
|
60
|
+
{item.href ? (
|
|
61
|
+
<a class="b-contact__item" href={item.href} target={target(item.href)} rel={rel(item.href)}>{inner}</a>
|
|
62
|
+
) : (
|
|
63
|
+
<div class="b-contact__item">{inner}</div>
|
|
64
|
+
)}
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</ul>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{note && <p class="b-contact__note" set:html={inlineMarkdown(note)} />}
|
|
72
|
+
</div>
|
|
73
|
+
</section>
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
.b-contact {
|
|
77
|
+
max-width: 72rem;
|
|
78
|
+
margin-inline: auto;
|
|
79
|
+
padding-inline: 1rem;
|
|
80
|
+
}
|
|
81
|
+
/* Adaptive panel: a section surface that flips light↔dark with the theme, lifted
|
|
82
|
+
off the page by a border + shadow, with a soft brand-gold wash for presence. */
|
|
83
|
+
.b-contact__panel {
|
|
84
|
+
position: relative;
|
|
85
|
+
isolation: isolate;
|
|
86
|
+
overflow: hidden;
|
|
87
|
+
display: grid;
|
|
88
|
+
grid-template-columns: 1fr;
|
|
89
|
+
gap: 1.5rem 2.5rem;
|
|
90
|
+
padding: clamp(1.75rem, 4vw, 3rem);
|
|
91
|
+
border-radius: var(--radius);
|
|
92
|
+
background: var(--bg-section);
|
|
93
|
+
color: var(--fg);
|
|
94
|
+
border: 1px solid var(--border);
|
|
95
|
+
box-shadow: var(--shadow-md);
|
|
96
|
+
}
|
|
97
|
+
/* Brand wash — a gold glow in the top-right, behind the content (z-index below the
|
|
98
|
+
panel's own children via isolation). Adds brand presence in both themes without
|
|
99
|
+
tinting the text ground, so contrast is never at risk. */
|
|
100
|
+
.b-contact__panel::before {
|
|
101
|
+
content: '';
|
|
102
|
+
position: absolute;
|
|
103
|
+
inset: 0;
|
|
104
|
+
z-index: -1;
|
|
105
|
+
background: radial-gradient(115% 130% at 100% 0%, color-mix(in srgb, var(--gold) 20%, transparent), transparent 62%);
|
|
106
|
+
pointer-events: none;
|
|
107
|
+
}
|
|
108
|
+
@media (min-width: 48rem) {
|
|
109
|
+
.b-contact__panel { grid-template-columns: 1.2fr 1fr; align-items: center; }
|
|
110
|
+
}
|
|
111
|
+
.b-contact__head { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
112
|
+
.b-contact__eyebrow {
|
|
113
|
+
margin: 0;
|
|
114
|
+
display: inline-flex; align-items: center; gap: 0.6rem;
|
|
115
|
+
font-family: var(--font-heading);
|
|
116
|
+
font-size: 0.75rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
|
|
117
|
+
color: var(--accent);
|
|
118
|
+
}
|
|
119
|
+
/* Short gold rule carries the brand into the eyebrow without colouring the text. */
|
|
120
|
+
.b-contact__eyebrow::before {
|
|
121
|
+
content: ''; flex: 0 0 auto; width: 1.5rem; height: 2px; border-radius: 2px;
|
|
122
|
+
background: var(--gold);
|
|
123
|
+
}
|
|
124
|
+
.b-contact__title {
|
|
125
|
+
margin: 0;
|
|
126
|
+
font-family: var(--font-display, var(--font-heading));
|
|
127
|
+
font-size: clamp(1.9rem, 4vw, 2.8rem);
|
|
128
|
+
line-height: 1.05;
|
|
129
|
+
text-wrap: balance;
|
|
130
|
+
color: var(--fg);
|
|
131
|
+
}
|
|
132
|
+
.b-contact__subtitle {
|
|
133
|
+
margin: 0.15rem 0 0;
|
|
134
|
+
font-family: var(--font-heading);
|
|
135
|
+
font-weight: 600;
|
|
136
|
+
color: var(--accent);
|
|
137
|
+
}
|
|
138
|
+
.b-contact__items {
|
|
139
|
+
list-style: none;
|
|
140
|
+
margin: 0; padding: 0;
|
|
141
|
+
display: flex; flex-direction: column; gap: 0.6rem;
|
|
142
|
+
}
|
|
143
|
+
.b-contact__item {
|
|
144
|
+
display: flex; align-items: center; gap: 0.75rem;
|
|
145
|
+
padding: 0.7rem 1rem;
|
|
146
|
+
border-radius: var(--radius-sm);
|
|
147
|
+
background: var(--bg-surface);
|
|
148
|
+
border: 1px solid var(--border);
|
|
149
|
+
color: var(--fg);
|
|
150
|
+
font-family: var(--font-heading);
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
font-size: 0.95rem;
|
|
153
|
+
text-decoration: none;
|
|
154
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
155
|
+
}
|
|
156
|
+
a.b-contact__item:hover {
|
|
157
|
+
border-color: var(--gold);
|
|
158
|
+
background: color-mix(in srgb, var(--gold) 8%, var(--bg-surface));
|
|
159
|
+
}
|
|
160
|
+
a.b-contact__item:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
|
161
|
+
.b-contact__icon { display: inline-flex; color: var(--gold); flex: 0 0 auto; }
|
|
162
|
+
.b-contact__value { min-width: 0; overflow-wrap: anywhere; }
|
|
163
|
+
.b-contact__note {
|
|
164
|
+
grid-column: 1 / -1;
|
|
165
|
+
margin: 0;
|
|
166
|
+
padding-top: 1.25rem;
|
|
167
|
+
border-top: 1px solid var(--border);
|
|
168
|
+
color: var(--muted);
|
|
169
|
+
font-size: 0.9rem;
|
|
170
|
+
line-height: 1.6;
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Documents — a tidy list of files (newsletters, minutes, policies, forms): each row
|
|
4
|
+
* a type badge, the title, its date and an optional note, newest first, optionally
|
|
5
|
+
* grouped by year. Files open in the browser's own viewer (a PDF in a new tab) rather
|
|
6
|
+
* than an in-page viewer, which works poorly on phones. Logic lives in lib/documents.
|
|
7
|
+
*/
|
|
8
|
+
import Heading from '../blocks/Heading.astro';
|
|
9
|
+
import Icon from '../Icon.astro';
|
|
10
|
+
import { documentGroups, type DocumentItem } from '../../lib/documents';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
title?: string;
|
|
14
|
+
intro?: string;
|
|
15
|
+
groupByYear?: boolean;
|
|
16
|
+
items?: DocumentItem[];
|
|
17
|
+
}
|
|
18
|
+
const { title, intro, groupByYear = false, items = [] } = Astro.props;
|
|
19
|
+
const groups = documentGroups(items, groupByYear);
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<section class="b-docs">
|
|
23
|
+
{(title || intro) && (
|
|
24
|
+
<div class="b-docs__head">
|
|
25
|
+
{title && <Heading text={title} level={2} />}
|
|
26
|
+
{intro && <p class="b-docs__intro">{intro}</p>}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
{items.length === 0 ? (
|
|
30
|
+
<p class="b-docs__intro">No documents yet.</p>
|
|
31
|
+
) : (
|
|
32
|
+
groups.map((g) => (
|
|
33
|
+
<div class="b-docs__group">
|
|
34
|
+
{g.year && <h3 class="b-docs__year">{g.year}</h3>}
|
|
35
|
+
<ul class="b-docs__list">
|
|
36
|
+
{g.items.map((d) => (
|
|
37
|
+
<li>
|
|
38
|
+
<a class="b-docs__row" href={d.file} target={d.kind === 'PDF' ? '_blank' : undefined} rel={d.kind === 'PDF' ? 'noopener' : undefined}>
|
|
39
|
+
<span class="b-docs__icon" aria-hidden="true"><Icon name="document" size={22} /></span>
|
|
40
|
+
<span class="b-docs__body">
|
|
41
|
+
<span class="b-docs__title">{d.title}</span>
|
|
42
|
+
{(d.dateLabel || d.note) && (
|
|
43
|
+
<span class="b-docs__meta">{[d.dateLabel, d.note].filter(Boolean).join(' · ')}</span>
|
|
44
|
+
)}
|
|
45
|
+
</span>
|
|
46
|
+
<span class="b-docs__kind">{d.kind}</span>
|
|
47
|
+
</a>
|
|
48
|
+
</li>
|
|
49
|
+
))}
|
|
50
|
+
</ul>
|
|
51
|
+
</div>
|
|
52
|
+
))
|
|
53
|
+
)}
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.b-docs {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 1.5rem;
|
|
61
|
+
max-width: var(--max);
|
|
62
|
+
margin-inline: auto;
|
|
63
|
+
padding-inline: 1rem;
|
|
64
|
+
width: 100%;
|
|
65
|
+
}
|
|
66
|
+
.b-docs__head {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
gap: 0.5rem;
|
|
70
|
+
}
|
|
71
|
+
.b-docs__intro {
|
|
72
|
+
margin: 0;
|
|
73
|
+
color: var(--muted);
|
|
74
|
+
line-height: 1.6;
|
|
75
|
+
}
|
|
76
|
+
.b-docs__group {
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
gap: 0.5rem;
|
|
80
|
+
}
|
|
81
|
+
.b-docs__year {
|
|
82
|
+
margin: 0;
|
|
83
|
+
font-size: 0.8125rem;
|
|
84
|
+
font-weight: 600;
|
|
85
|
+
letter-spacing: 0.08em;
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
color: var(--muted);
|
|
88
|
+
}
|
|
89
|
+
.b-docs__list {
|
|
90
|
+
list-style: none;
|
|
91
|
+
margin: 0;
|
|
92
|
+
padding: 0;
|
|
93
|
+
border: var(--card-border);
|
|
94
|
+
border-radius: var(--radius);
|
|
95
|
+
background: var(--bg-surface);
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
}
|
|
98
|
+
.b-docs__list li + li {
|
|
99
|
+
border-top: var(--card-border);
|
|
100
|
+
}
|
|
101
|
+
.b-docs__row {
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
gap: 0.9rem;
|
|
105
|
+
padding: 0.85rem 1rem;
|
|
106
|
+
color: var(--fg);
|
|
107
|
+
text-decoration: none;
|
|
108
|
+
}
|
|
109
|
+
.b-docs__row:hover {
|
|
110
|
+
background: var(--bg-section);
|
|
111
|
+
}
|
|
112
|
+
.b-docs__row:focus-visible {
|
|
113
|
+
outline: 2px solid var(--gold);
|
|
114
|
+
outline-offset: -2px;
|
|
115
|
+
}
|
|
116
|
+
.b-docs__icon {
|
|
117
|
+
flex: none;
|
|
118
|
+
color: var(--gold);
|
|
119
|
+
display: grid;
|
|
120
|
+
}
|
|
121
|
+
.b-docs__body {
|
|
122
|
+
flex: 1;
|
|
123
|
+
min-width: 0;
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
gap: 0.15rem;
|
|
127
|
+
}
|
|
128
|
+
.b-docs__title {
|
|
129
|
+
font-weight: 600;
|
|
130
|
+
overflow-wrap: anywhere;
|
|
131
|
+
}
|
|
132
|
+
.b-docs__meta {
|
|
133
|
+
font-size: 0.875rem;
|
|
134
|
+
color: var(--muted);
|
|
135
|
+
}
|
|
136
|
+
.b-docs__kind {
|
|
137
|
+
flex: none;
|
|
138
|
+
font-size: 0.75rem;
|
|
139
|
+
font-weight: 600;
|
|
140
|
+
letter-spacing: 0.04em;
|
|
141
|
+
padding: 0.2rem 0.55rem;
|
|
142
|
+
border-radius: 999px;
|
|
143
|
+
background: var(--bg-tag);
|
|
144
|
+
color: var(--fg);
|
|
145
|
+
}
|
|
146
|
+
</style>
|