ferst-core 0.7.0 → 0.7.4

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 (38) hide show
  1. package/components/BlockRenderer.astro +6 -0
  2. package/components/Pagination.astro +51 -0
  3. package/components/blocks/Heading.astro +47 -46
  4. package/components/blocks/Prose.astro +10 -2
  5. package/components/blocks/Quote.astro +40 -38
  6. package/components/recipes/Accordion.astro +91 -84
  7. package/components/recipes/ContactCard.astro +172 -170
  8. package/components/recipes/Document.astro +152 -0
  9. package/components/recipes/FeatureCards.astro +112 -111
  10. package/components/recipes/Hero.astro +120 -8
  11. package/components/recipes/LinkRow.astro +91 -0
  12. package/components/recipes/MediaCards.astro +213 -212
  13. package/components/recipes/Notice.astro +90 -89
  14. package/components/recipes/Stats.astro +152 -145
  15. package/components/recipes/Table.astro +98 -97
  16. package/components/recipes/Tabs.astro +134 -133
  17. package/components/recipes/Tiles.astro +287 -271
  18. package/components/recipes/Timetable.astro +189 -0
  19. package/components/recipes/Video.astro +177 -157
  20. package/content/blocks.ts +68 -2
  21. package/content/cookies.ts +10 -0
  22. package/examples/primitives.ts +2 -0
  23. package/examples/recipes.ts +42 -36
  24. package/integration.mjs +83 -64
  25. package/layouts/Base.astro +481 -475
  26. package/lib/documents.ts +68 -62
  27. package/lib/embed-consent.ts +78 -73
  28. package/lib/inline-markdown.ts +12 -0
  29. package/lib/links.ts +12 -0
  30. package/lib/paginate.ts +23 -0
  31. package/lib/posts.ts +20 -2
  32. package/lib/timetable.ts +27 -0
  33. package/lib/video.ts +101 -56
  34. package/package.json +1 -1
  35. package/routes/cookie-policy.astro +27 -0
  36. package/routes/posts/index.astro +36 -31
  37. package/routes/posts/page/[page].astro +36 -0
  38. package/routes/posts/tag/[...tag].astro +41 -37
@@ -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
- interface Item {
21
- icon?: string;
22
- value: string;
23
- href?: string;
24
- }
25
- interface Props {
26
- eyebrow?: string;
27
- title: string;
28
- subtitle?: string;
29
- note?: string;
30
- items?: Item[];
31
- }
32
- const { eyebrow, title, subtitle, note, items = [] } = Astro.props;
33
- const rel = (href?: string) => (href && /^https?:\/\//.test(href) ? 'noopener' : undefined);
34
- const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank' : undefined);
35
- ---
36
-
37
- <section class="b-contact">
38
- <div class="b-contact__panel">
39
- <div class="b-contact__head">
40
- {eyebrow && <p class="b-contact__eyebrow">{eyebrow}</p>}
41
- <h2 class="b-contact__title">{title}</h2>
42
- {subtitle && <p class="b-contact__subtitle">{subtitle}</p>}
43
- </div>
44
-
45
- {items.length > 0 && (
46
- <ul class="b-contact__items">
47
- {items.map((item) => {
48
- const inner = (
49
- <>
50
- {item.icon && isIconKey(item.icon) && (
51
- <span class="b-contact__icon" aria-hidden="true"><Icon name={item.icon} size={18} /></span>
52
- )}
53
- <span class="b-contact__value">{item.value}</span>
54
- </>
55
- );
56
- return (
57
- <li>
58
- {item.href ? (
59
- <a class="b-contact__item" href={item.href} target={target(item.href)} rel={rel(item.href)}>{inner}</a>
60
- ) : (
61
- <div class="b-contact__item">{inner}</div>
62
- )}
63
- </li>
64
- );
65
- })}
66
- </ul>
67
- )}
68
-
69
- {note && <p class="b-contact__note">{note}</p>}
70
- </div>
71
- </section>
72
-
73
- <style>
74
- .b-contact {
75
- max-width: 72rem;
76
- margin-inline: auto;
77
- padding-inline: 1rem;
78
- }
79
- /* Adaptive panel: a section surface that flips light↔dark with the theme, lifted
80
- off the page by a border + shadow, with a soft brand-gold wash for presence. */
81
- .b-contact__panel {
82
- position: relative;
83
- isolation: isolate;
84
- overflow: hidden;
85
- display: grid;
86
- grid-template-columns: 1fr;
87
- gap: 1.5rem 2.5rem;
88
- padding: clamp(1.75rem, 4vw, 3rem);
89
- border-radius: var(--radius);
90
- background: var(--bg-section);
91
- color: var(--fg);
92
- border: 1px solid var(--border);
93
- box-shadow: var(--shadow-md);
94
- }
95
- /* Brand wash — a gold glow in the top-right, behind the content (z-index below the
96
- panel's own children via isolation). Adds brand presence in both themes without
97
- tinting the text ground, so contrast is never at risk. */
98
- .b-contact__panel::before {
99
- content: '';
100
- position: absolute;
101
- inset: 0;
102
- z-index: -1;
103
- background: radial-gradient(115% 130% at 100% 0%, color-mix(in srgb, var(--gold) 20%, transparent), transparent 62%);
104
- pointer-events: none;
105
- }
106
- @media (min-width: 48rem) {
107
- .b-contact__panel { grid-template-columns: 1.2fr 1fr; align-items: center; }
108
- }
109
- .b-contact__head { display: flex; flex-direction: column; gap: 0.5rem; }
110
- .b-contact__eyebrow {
111
- margin: 0;
112
- display: inline-flex; align-items: center; gap: 0.6rem;
113
- font-family: var(--font-heading);
114
- font-size: 0.75rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
115
- color: var(--accent);
116
- }
117
- /* Short gold rule carries the brand into the eyebrow without colouring the text. */
118
- .b-contact__eyebrow::before {
119
- content: ''; flex: 0 0 auto; width: 1.5rem; height: 2px; border-radius: 2px;
120
- background: var(--gold);
121
- }
122
- .b-contact__title {
123
- margin: 0;
124
- font-family: var(--font-display, var(--font-heading));
125
- font-size: clamp(1.9rem, 4vw, 2.8rem);
126
- line-height: 1.05;
127
- text-wrap: balance;
128
- color: var(--fg);
129
- }
130
- .b-contact__subtitle {
131
- margin: 0.15rem 0 0;
132
- font-family: var(--font-heading);
133
- font-weight: 600;
134
- color: var(--accent);
135
- }
136
- .b-contact__items {
137
- list-style: none;
138
- margin: 0; padding: 0;
139
- display: flex; flex-direction: column; gap: 0.6rem;
140
- }
141
- .b-contact__item {
142
- display: flex; align-items: center; gap: 0.75rem;
143
- padding: 0.7rem 1rem;
144
- border-radius: var(--radius-sm);
145
- background: var(--bg-surface);
146
- border: 1px solid var(--border);
147
- color: var(--fg);
148
- font-family: var(--font-heading);
149
- font-weight: 600;
150
- font-size: 0.95rem;
151
- text-decoration: none;
152
- transition: border-color 0.15s ease, background 0.15s ease;
153
- }
154
- a.b-contact__item:hover {
155
- border-color: var(--gold);
156
- background: color-mix(in srgb, var(--gold) 8%, var(--bg-surface));
157
- }
158
- a.b-contact__item:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
159
- .b-contact__icon { display: inline-flex; color: var(--gold); flex: 0 0 auto; }
160
- .b-contact__value { min-width: 0; overflow-wrap: anywhere; }
161
- .b-contact__note {
162
- grid-column: 1 / -1;
163
- margin: 0;
164
- padding-top: 1.25rem;
165
- border-top: 1px solid var(--border);
166
- color: var(--muted);
167
- font-size: 0.9rem;
168
- line-height: 1.6;
169
- }
170
- </style>
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,152 @@
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>