ferst-core 0.7.8 → 0.7.10

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,227 +1,232 @@
1
- # Sveltia CMS — the content editor for a Ferst site. SINGLE SOURCE: this file lives
2
- # in ferst-core and is written to dist/admin/config.yml at build by `ferst-cms-config`,
3
- # so every client's collections are identical and always match the engine. The client
4
- # repo commits NO config.yml — it is a build product, so it can never drift by hand.
5
- #
6
- # The backend repo (owner/name), git-proxy origin, its /git api_root and this
7
- # deployment's branch are injected at build by ferst-cms-config.
8
- backend:
9
- name: github
10
- repo: __REPO__
11
- branch: __BRANCH__
12
- base_url: __BASE_URL__
13
- auth_endpoint: auth
14
- api_root: __API_ROOT__
15
-
16
- media_folder: public/uploads
17
- public_folder: /uploads
18
-
19
- collections:
20
- - name: settings
21
- label: Site settings
22
- files:
23
- - name: theme
24
- label: Design tokens
25
- file: src/content/themeSettings/index.json
26
- icon: palette
27
- description: "Edit your brand in the Theme Studio, where you preview changes live before publishing — open portal.ferst.co.uk, choose this site, then the Design tab. The fields here still work as a fallback."
28
- fields:
29
- - { name: brand, label: Brand colour, widget: color, default: '#0E4D4A', hint: Buttons, links, icons, highlights. }
30
- - { name: ink, label: Ink colour, widget: color, default: '#0C2123', hint: Headings and body text. }
31
- - { name: surface, label: Surface colour, widget: color, default: '#FFFFFF', hint: Page background. }
32
- - name: fonts
33
- label: Typography
34
- widget: object
35
- collapsed: false
36
- fields:
37
- - name: heading
38
- label: Heading font
39
- widget: select
40
- required: false
41
- default: ''
42
- options: &fonts
43
- - { label: 'Default (theme)', value: '' }
44
- - { label: 'Poppins', value: 'Poppins, system-ui, sans-serif' }
45
- - { label: 'Inter', value: 'Inter, system-ui, sans-serif' }
46
- - { label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" }
47
- - { label: 'Georgia', value: "Georgia, 'Times New Roman', serif" }
48
- - { label: 'System', value: 'system-ui, sans-serif' }
49
- - { name: body, label: Body font, widget: select, required: false, default: '', options: *fonts }
50
- - { name: display, label: Display font, widget: select, required: false, default: '', options: *fonts }
51
- - { name: corners, label: Corners, widget: select, default: rounded, options: [{ label: Sharp, value: sharp }, { label: Rounded, value: rounded }, { label: Soft, value: soft }] }
52
- - { name: elevation, label: Elevation, widget: select, default: raised, options: [{ label: Raised, value: raised }, { label: Flat, value: flat }] }
53
- - { name: stroke, label: Card stroke, widget: select, default: 'on', options: [{ label: 'On', value: 'on' }, { label: 'Off', value: 'off' }] }
54
- - { name: fields, label: Input fields, widget: select, default: boxed, options: [{ label: Boxed, value: boxed }, { label: Filled, value: filled }, { label: Underline, value: underline }] }
55
- - { name: darkDepth, label: Dark depth, widget: number, value_type: float, min: 0.14, max: 0.34, step: 0.01, default: 0.21 }
56
- - { name: darkChroma, label: Dark colour intensity, widget: number, value_type: float, min: 0, max: 0.15, step: 0.01, default: 0.05 }
57
- - name: darkOverrides
58
- label: Dark colour overrides (optional)
59
- widget: object
60
- collapsed: true
61
- fields:
62
- - { name: ground, label: Ground, widget: string, required: false }
63
- - { name: text, label: Text, widget: string, required: false }
64
- - { name: accent, label: Accent, widget: string, required: false }
65
-
66
- - name: identity
67
- label: Identity & contact
68
- file: src/content/siteSettings/index.json
69
- icon: info
70
- fields:
71
- - name: identity
72
- label: Identity
73
- widget: object
74
- fields:
75
- - { name: siteTitle, label: Site title, widget: string }
76
- - { name: shortName, label: Short name, widget: string }
77
- - { name: description, label: Description, widget: text }
78
- - { name: location, label: Location, widget: string, required: false }
79
- - name: contact
80
- label: Contact
81
- widget: object
82
- fields:
83
- - { name: phoneDisplay, label: Phone (display), widget: string, required: false }
84
- - { name: phoneTel, label: 'Phone (tel: digits)', widget: string, required: false }
85
- - { name: email, label: Email, widget: string, required: false }
86
- - { name: facebookUrl, label: Facebook URL, widget: string, required: false }
87
-
88
- - name: logos
89
- label: Logos
90
- file: src/content/logoSettings/index.json
91
- icon: image
92
- fields:
93
- - { name: primaryLogoLight, label: Header logo (light), widget: image, required: false }
94
- - { name: primaryLogoDark, label: Header logo (dark), widget: image, required: false }
95
- - { name: secondaryLogoLight, label: Footer logo (light), widget: image, required: false }
96
- - { name: secondaryLogoDark, label: Footer logo (dark), widget: image, required: false }
97
-
98
- - name: navbar
99
- label: Navigation
100
- file: src/content/navbarSettings/index.json
101
- icon: menu
102
- fields:
103
- - name: primary
104
- label: Primary links
105
- widget: list
106
- fields:
107
- - { name: label, label: Label, widget: string }
108
- - { name: href, label: Link, widget: string }
109
- - name: cta
110
- label: Header button
111
- widget: object
112
- required: false
113
- hint: The button at the right of the header (and in the footer). Leave as Contact → /contact/ unless your contact page lives elsewhere.
114
- fields:
115
- - { name: label, label: Label, widget: string, default: Contact }
116
- - { name: href, label: Link, widget: string, default: /contact/ }
117
-
118
- - name: footer
119
- label: Footer
120
- file: src/content/footerSettings/index.json
121
- icon: table_rows
122
- fields:
123
- - name: footer
124
- label: Footer links
125
- widget: list
126
- fields:
127
- - { name: label, label: Label, widget: string }
128
- - { name: href, label: Link, widget: string }
129
- - name: groups
130
- label: Footer columns
131
- widget: list
132
- required: false
133
- hint: Headed columns for policies, safeguarding, documents and forms — keep the main menu short.
134
- fields:
135
- - { name: heading, label: Heading, widget: string }
136
- - name: links
137
- label: Links
138
- widget: list
139
- fields:
140
- - { name: label, label: Label, widget: string }
141
- - { name: href, label: Link, widget: string }
142
-
143
- - name: calendar-settings
144
- label: Calendar
145
- file: src/content/calendarSettings/index.json
146
- icon: calendar_today
147
- fields:
148
- - { name: title, label: Heading, widget: string, default: Calendar }
149
- - { name: intro, label: Intro, widget: text, required: false }
150
- - { name: embedUrl, label: Embed URL, widget: string, required: false }
151
- - { name: publicUrl, label: Public link, widget: string, required: false }
152
- - { name: height, label: Height (px), widget: number, default: 700, value_type: int, min: 200, max: 2000 }
153
- - { name: provider, label: Provider name, widget: string, default: Google Calendar }
154
- - { name: weekStart, label: Week starts on, widget: select, default: monday, options: [{ label: "Monday", value: monday }, { label: "Sunday", value: sunday }] }
155
-
156
- - name: posts
157
- label: Posts
158
- label_singular: Post
159
- folder: src/content/posts
160
- create: true
161
- slug: "{{slug}}"
162
- extension: md
163
- format: frontmatter
164
- icon: article
165
- summary: "{{title}} — {{date}}"
166
- sortable_fields: [date, title]
167
- fields:
168
- - { name: title, label: Title, widget: string }
169
- - { name: date, label: Date, widget: datetime, picker_utc: true }
170
- - { name: summary, label: Summary, widget: text, required: false }
171
- - name: tags
172
- label: Tags
173
- widget: relation
174
- required: false
175
- collection: tags
176
- value_field: "{{slug}}"
177
- display_fields: [label]
178
- search_fields: [label]
179
- multiple: true
180
- - { name: heroImage, label: Hero image, widget: image, required: false }
181
- - { name: attachment, label: Attachment (e.g. PDF), widget: file, required: false }
182
- - { name: draft, label: Draft, widget: boolean, default: false, required: false }
183
- - { name: body, label: Body, widget: markdown, required: false }
184
-
185
- - name: tags
186
- label: Tags
187
- label_singular: Tag
188
- folder: src/content/tags
189
- create: true
190
- slug: "{{slug}}"
191
- extension: json
192
- format: json
193
- icon: label
194
- identifier_field: label
195
- fields:
196
- - { name: label, label: Label, widget: string }
197
- - { name: description, label: Description, widget: text, required: false }
198
- - { name: hidden, label: Hidden, widget: boolean, default: false, required: false }
199
-
200
- - name: pages
201
- label: Pages
202
- label_singular: Page
203
- description: "See each page and request a change — our team makes it and sends it back for you to check. You don't edit the page layout here."
204
- folder: src/content/pages
205
- extension: json
206
- format: json
207
- icon: description
208
- nested: { depth: 3, summary: "{{title}}" }
209
- summary: "{{title}}"
210
- identifier_field: title
211
- fields:
212
- - { name: title, label: Page title, widget: string }
213
- - name: requests
214
- label: Requested changes
215
- label_singular: Request
216
- widget: list
217
- required: false
218
- summary: "{{fields.text}}"
219
- fields:
220
- - { name: text, label: "What would you like changed?", widget: text }
221
- - { name: status, label: Status, widget: select, default: open, options: [{ label: Open, value: open }, { label: Done, value: done }] }
222
- - { name: description, label: SEO description, widget: hidden, required: false }
223
- - { name: heroImage, widget: hidden, required: false }
224
- - { name: align, widget: hidden, required: false }
225
- - { name: templateId, widget: hidden, required: false }
226
- - { name: draft, widget: hidden, required: false }
227
- - { name: blocks, widget: hidden }
1
+ # Sveltia CMS — the content editor for a Ferst site. SINGLE SOURCE: this file lives
2
+ # in ferst-core and is written to dist/admin/config.yml at build by `ferst-cms-config`,
3
+ # so every client's collections are identical and always match the engine. The client
4
+ # repo commits NO config.yml — it is a build product, so it can never drift by hand.
5
+ #
6
+ # The backend repo (owner/name), git-proxy origin, its /git api_root and this
7
+ # deployment's branch are injected at build by ferst-cms-config.
8
+ backend:
9
+ name: github
10
+ repo: __REPO__
11
+ branch: __BRANCH__
12
+ base_url: __BASE_URL__
13
+ auth_endpoint: auth
14
+ api_root: __API_ROOT__
15
+
16
+ media_folder: public/uploads
17
+ public_folder: /uploads
18
+
19
+ collections:
20
+ - name: settings
21
+ label: Site settings
22
+ files:
23
+ - name: theme
24
+ label: Design tokens
25
+ file: src/content/themeSettings/index.json
26
+ icon: palette
27
+ description: "Edit your brand in the Theme Studio, where you preview changes live before publishing — open portal.ferst.co.uk, choose this site, then the Design tab. The fields here still work as a fallback."
28
+ fields:
29
+ - { name: brand, label: Brand colour, widget: color, default: '#0E4D4A', hint: Buttons, links, icons, highlights. }
30
+ - { name: ink, label: Ink colour, widget: color, default: '#0C2123', hint: Headings and body text. }
31
+ - { name: surface, label: Surface colour, widget: color, default: '#FFFFFF', hint: Page background. }
32
+ - name: fonts
33
+ label: Typography
34
+ widget: object
35
+ collapsed: false
36
+ fields:
37
+ - name: heading
38
+ label: Heading font
39
+ widget: select
40
+ required: false
41
+ default: ''
42
+ options: &fonts
43
+ - { label: 'Default (theme)', value: '' }
44
+ - { label: 'Poppins', value: 'Poppins, system-ui, sans-serif' }
45
+ - { label: 'Inter', value: 'Inter, system-ui, sans-serif' }
46
+ - { label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" }
47
+ - { label: 'Georgia', value: "Georgia, 'Times New Roman', serif" }
48
+ - { label: 'System', value: 'system-ui, sans-serif' }
49
+ - { name: body, label: Body font, widget: select, required: false, default: '', options: *fonts }
50
+ - { name: display, label: Display font, widget: select, required: false, default: '', options: *fonts }
51
+ - { name: corners, label: Corners, widget: select, default: rounded, options: [{ label: Sharp, value: sharp }, { label: Rounded, value: rounded }, { label: Soft, value: soft }] }
52
+ - { name: elevation, label: Elevation, widget: select, default: raised, options: [{ label: Raised, value: raised }, { label: Flat, value: flat }] }
53
+ - { name: stroke, label: Card stroke, widget: select, default: 'on', options: [{ label: 'On', value: 'on' }, { label: 'Off', value: 'off' }] }
54
+ - { name: fields, label: Input fields, widget: select, default: boxed, options: [{ label: Boxed, value: boxed }, { label: Filled, value: filled }, { label: Underline, value: underline }] }
55
+ - { name: darkDepth, label: Dark depth, widget: number, value_type: float, min: 0.14, max: 0.34, step: 0.01, default: 0.21 }
56
+ - { name: darkChroma, label: Dark colour intensity, widget: number, value_type: float, min: 0, max: 0.15, step: 0.01, default: 0.05 }
57
+ - name: darkOverrides
58
+ label: Dark colour overrides (optional)
59
+ widget: object
60
+ collapsed: true
61
+ fields:
62
+ - { name: ground, label: Ground, widget: string, required: false }
63
+ - { name: text, label: Text, widget: string, required: false }
64
+ - { name: accent, label: Accent, widget: string, required: false }
65
+
66
+ - name: identity
67
+ label: Identity & contact
68
+ file: src/content/siteSettings/index.json
69
+ icon: info
70
+ fields:
71
+ - name: identity
72
+ label: Identity
73
+ widget: object
74
+ fields:
75
+ - { name: siteTitle, label: Site title, widget: string }
76
+ - { name: shortName, label: Short name, widget: string }
77
+ - { name: description, label: Description, widget: text }
78
+ - { name: location, label: Location, widget: string, required: false }
79
+ - name: contact
80
+ label: Contact
81
+ widget: object
82
+ fields:
83
+ - { name: phoneDisplay, label: Phone (display), widget: string, required: false }
84
+ - { name: phoneTel, label: 'Phone (tel: digits)', widget: string, required: false }
85
+ - { name: email, label: Email, widget: string, required: false }
86
+ - { name: facebookUrl, label: Facebook URL, widget: string, required: false }
87
+
88
+ - name: logos
89
+ label: Logos
90
+ file: src/content/logoSettings/index.json
91
+ icon: image
92
+ fields:
93
+ - { name: primaryLogoLight, label: Header logo (light), widget: image, required: false }
94
+ - { name: primaryLogoDark, label: Header logo (dark), widget: image, required: false }
95
+ - { name: secondaryLogoLight, label: Footer logo (light), widget: image, required: false }
96
+ - { name: secondaryLogoDark, label: Footer logo (dark), widget: image, required: false }
97
+
98
+ - name: navbar
99
+ label: Navigation
100
+ file: src/content/navbarSettings/index.json
101
+ icon: menu
102
+ fields:
103
+ - name: primary
104
+ label: Primary links
105
+ widget: list
106
+ fields:
107
+ - { name: label, label: Label, widget: string }
108
+ - { name: href, label: Link, widget: string }
109
+ - name: cta
110
+ label: Header button
111
+ widget: object
112
+ required: false
113
+ hint: The button at the right of the header (and in the footer). Leave as Contact → /contact/ unless your contact page lives elsewhere.
114
+ fields:
115
+ - { name: label, label: Label, widget: string, default: Contact }
116
+ - { name: href, label: Link, widget: string, default: /contact/ }
117
+
118
+ - name: footer
119
+ label: Footer
120
+ file: src/content/footerSettings/index.json
121
+ icon: table_rows
122
+ fields:
123
+ - name: footer
124
+ label: Footer links
125
+ widget: list
126
+ fields:
127
+ - { name: label, label: Label, widget: string }
128
+ - { name: href, label: Link, widget: string }
129
+ - name: groups
130
+ label: Footer columns
131
+ widget: list
132
+ required: false
133
+ hint: Headed columns for policies, safeguarding, documents and forms — keep the main menu short.
134
+ fields:
135
+ - { name: heading, label: Heading, widget: string }
136
+ - name: links
137
+ label: Links
138
+ widget: list
139
+ fields:
140
+ - { name: label, label: Label, widget: string }
141
+ - { name: href, label: Link, widget: string }
142
+
143
+ - name: calendar-settings
144
+ label: Calendar
145
+ file: src/content/calendarSettings/index.json
146
+ icon: calendar_today
147
+ fields:
148
+ - { name: title, label: Heading, widget: string, default: Calendar }
149
+ - { name: intro, label: Intro, widget: text, required: false }
150
+ - { name: embedUrl, label: Embed URL, widget: string, required: false }
151
+ - { name: publicUrl, label: Public link, widget: string, required: false }
152
+ - { name: height, label: Height (px), widget: number, default: 700, value_type: int, min: 200, max: 2000 }
153
+ - { name: provider, label: Provider name, widget: string, default: Google Calendar }
154
+ - { name: weekStart, label: Week starts on, widget: select, default: monday, options: [{ label: "Monday", value: monday }, { label: "Sunday", value: sunday }] }
155
+
156
+ - name: posts
157
+ label: Posts
158
+ label_singular: Post
159
+ folder: src/content/posts
160
+ create: true
161
+ slug: "{{slug}}"
162
+ extension: md
163
+ format: frontmatter
164
+ icon: article
165
+ summary: "{{title}} — {{date}}"
166
+ sortable_fields: [date, title]
167
+ fields:
168
+ - { name: title, label: Title, widget: string }
169
+ - { name: date, label: Date, widget: datetime, picker_utc: true }
170
+ - { name: summary, label: Summary, widget: text, required: false }
171
+ - name: tags
172
+ label: Tags
173
+ widget: relation
174
+ required: false
175
+ collection: tags
176
+ value_field: "{{slug}}"
177
+ display_fields: [label]
178
+ search_fields: [label]
179
+ multiple: true
180
+ - { name: heroImage, label: Hero image, widget: image, required: false }
181
+ - { name: attachment, label: Attachment (e.g. PDF), widget: file, required: false }
182
+ - { name: draft, label: Draft, widget: boolean, default: false, required: false }
183
+ - { name: body, label: Body, widget: markdown, required: false }
184
+
185
+ - name: tags
186
+ label: Tags
187
+ label_singular: Tag
188
+ folder: src/content/tags
189
+ create: true
190
+ slug: "{{slug}}"
191
+ extension: json
192
+ format: json
193
+ icon: label
194
+ identifier_field: label
195
+ fields:
196
+ - { name: label, label: Label, widget: string }
197
+ - { name: description, label: Description, widget: text, required: false }
198
+ - { name: hidden, label: Hidden, widget: boolean, default: false, required: false }
199
+
200
+ - name: pages
201
+ label: Pages
202
+ label_singular: Page
203
+ description: "See each page and request a change — our team makes it and sends it back for you to check. You don't edit the page layout here."
204
+ folder: src/content/pages
205
+ extension: json
206
+ format: json
207
+ icon: description
208
+ nested: { depth: 3, summary: "{{title}}" }
209
+ summary: "{{title}}"
210
+ identifier_field: title
211
+ fields:
212
+ - { name: title, label: Page title, widget: string }
213
+ - name: requests
214
+ label: Requested changes
215
+ label_singular: Request
216
+ widget: list
217
+ required: false
218
+ summary: "{{fields.text}}"
219
+ fields:
220
+ - { name: text, label: "What would you like changed?", widget: text }
221
+ - { name: status, label: Status, widget: select, default: open, options: [{ label: Open, value: open }, { label: Done, value: done }] }
222
+ # Stamped by the platform when the request is raised with the team (who, when, the
223
+ # tracking number). Hidden, but kept on every save so a request is raised only once.
224
+ - { name: issue, widget: hidden, required: false }
225
+ - { name: by, widget: hidden, required: false }
226
+ - { name: at, widget: hidden, required: false }
227
+ - { name: description, label: SEO description, widget: hidden, required: false }
228
+ - { name: heroImage, widget: hidden, required: false }
229
+ - { name: align, widget: hidden, required: false }
230
+ - { name: templateId, widget: hidden, required: false }
231
+ - { name: draft, widget: hidden, required: false }
232
+ - { name: blocks, widget: hidden }
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { inlineMarkdown } from '../lib/inline-markdown';
2
3
  /**
3
4
  * CalendarEmbed — a consent-gated external calendar embed (e.g. Google Calendar).
4
5
  * Nothing loads from the provider until the visitor clicks "Load calendar", so no
@@ -38,7 +39,7 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
38
39
 
39
40
  <section class="b-calendar">
40
41
  {title && <h2 class="b-calendar__title">{title}</h2>}
41
- {intro && <p class="b-calendar__intro">{intro}</p>}
42
+ {intro && <p class="b-calendar__intro" set:html={inlineMarkdown(intro)} />}
42
43
 
43
44
  {!finalEmbedUrl ? (
44
45
  <p class="b-calendar__note">The calendar isn't set up yet.</p>
@@ -23,11 +23,13 @@ interface Props {
23
23
  image?: { src: string; alt?: string };
24
24
  media?: { image?: string; images?: string[]; video?: string; poster?: string; overlay?: number; link?: string; linkLabel?: string };
25
25
  layout?: 'centered' | 'split' | 'media';
26
- size?: 'standard' | 'tall' | 'screen';
26
+ size?: 'compact' | 'standard' | 'tall' | 'screen';
27
27
  parallax?: boolean;
28
28
  background?: 'none' | 'mesh';
29
+ fullBleed?: boolean;
30
+ focus?: string;
29
31
  }
30
- const { eyebrow, title, subtitle, cta, image, media, layout = 'centered', size = 'standard', parallax = false, background = 'none' } = Astro.props;
32
+ const { eyebrow, title, subtitle, cta, image, media, layout = 'centered', size = 'standard', parallax = false, background = 'none', fullBleed = false, focus } = Astro.props;
31
33
  // A YouTube / Vimeo link plays as an embedded background (after consent); anything else
32
34
  // in `video` is a file for a native <video>.
33
35
  const embed = media?.video ? backgroundEmbed(media.video) : null;
@@ -55,7 +57,8 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
55
57
  data-motion={motion}
56
58
  data-linked={bgLink ? 'true' : undefined}
57
59
  data-parallax={isMedia && parallax ? 'true' : undefined}
58
- style={isMedia ? `--hero-overlay: ${overlay}; ${bgImage}` : undefined}
60
+ data-full-bleed={isMedia && fullBleed ? 'true' : undefined}
61
+ style={isMedia ? `--hero-overlay: ${overlay}; ${bgImage}${focus ? `; --hero-focus: ${focus}` : ''}` : undefined}
59
62
  >
60
63
  {isMedia && (
61
64
  <div class="b-hero__bg" aria-hidden="true">
@@ -307,6 +310,21 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
307
310
  overflow: hidden;
308
311
  isolation: isolate;
309
312
  }
313
+ /* A narrow band for a short, wide header strip: close to the strip's own shape, so it
314
+ isn't cropped to its middle and blown up. Less padding and a smaller title to fit. */
315
+ .b-hero[data-size='compact'] {
316
+ min-height: clamp(8rem, 18vw, 16rem);
317
+ padding-block: clamp(1.25rem, 3vw, 2.25rem);
318
+ }
319
+ .b-hero[data-size='compact'] :global(h1) {
320
+ font-size: clamp(1.6rem, 3.4vw, 2.6rem);
321
+ }
322
+ /* Edge to edge across the screen, like a full-bleed banner. */
323
+ .b-hero[data-full-bleed='true'] {
324
+ width: 100vw;
325
+ margin-inline: calc(50% - 50vw);
326
+ border-radius: 0;
327
+ }
310
328
  .b-hero[data-size='tall'] {
311
329
  min-height: min(85vh, 52rem);
312
330
  }
@@ -330,7 +348,7 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
330
348
  z-index: -1;
331
349
  background-image: var(--hero-image, none);
332
350
  background-size: cover;
333
- background-position: center;
351
+ background-position: var(--hero-focus, center);
334
352
  background-color: var(--bg-section);
335
353
  }
336
354
  /* Parallax: give the background room to drift without uncovering an edge. */
@@ -342,6 +360,7 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
342
360
  width: 100%;
343
361
  height: 100%;
344
362
  object-fit: cover;
363
+ object-position: var(--hero-focus, center);
345
364
  }
346
365
  /* An embedded background player, sized to COVER the hero like object-fit (16:9 kept,
347
366
  overflow cropped). Never interactive; fades in once it has started. */
@@ -374,7 +393,7 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
374
393
  position: absolute;
375
394
  inset: 0;
376
395
  background-size: cover;
377
- background-position: center;
396
+ background-position: var(--hero-focus, center);
378
397
  opacity: 0;
379
398
  transition: opacity 1.6s ease-in-out;
380
399
  }
package/content/blocks.ts CHANGED
@@ -146,9 +146,19 @@ export const heroBlock = z.object({
146
146
  })
147
147
  .optional(),
148
148
  layout: z.enum(['centered', 'split', 'media']).default('centered'),
149
- /** How much of the screen a `media` hero takes: `standard` (a band), `tall`,
150
- * or `screen` — the full first screen, edge to edge (e.g. a video opener). */
151
- size: z.enum(['standard', 'tall', 'screen']).default('standard'),
149
+ /** How much of the screen a `media` hero takes: `compact` (a narrow band for a short,
150
+ * wide header strip, e.g. 1000 × 175), `standard` (a band), `tall`, or `screen` — the
151
+ * full first screen, edge to edge (e.g. a video opener). */
152
+ size: z.enum(['compact', 'standard', 'tall', 'screen']).default('standard'),
153
+ /** A `media` hero runs edge to edge across the screen, like a full-bleed banner
154
+ * (`screen` always does). */
155
+ fullBleed: z.boolean().default(false),
156
+ /** Which part of the background image shows when it's cropped: one or two positions,
157
+ * keywords or percentages (e.g. `center 30%`, `top`, `left 20%`). Default `center`. */
158
+ focus: z
159
+ .string()
160
+ .regex(/^(?:left|center|right|top|bottom|\d{1,3}%)(?: (?:left|center|right|top|bottom|\d{1,3}%))?$/, 'focus is one or two of left / center / right / top / bottom / a percentage, e.g. "center 30%"')
161
+ .optional(),
152
162
  /** A `media` hero's background drifts more slowly than the page as it scrolls (any
153
163
  * background: image, slides or video). Off on touch devices and under reduced motion. */
154
164
  parallax: z.boolean().default(false),
@@ -919,8 +929,11 @@ export const pageSchema = z.object({
919
929
  .array(
920
930
  z.object({
921
931
  text: z.string(),
932
+ /** Stamped by the platform when the request becomes an issue in the site's repo:
933
+ * when, who asked (from their session) and the issue number. */
922
934
  at: z.string().optional(),
923
935
  by: z.string().optional(),
936
+ issue: z.number().int().optional(),
924
937
  status: z.enum(['open', 'done']).default('open'),
925
938
  }),
926
939
  )
@@ -431,6 +431,7 @@ const metaDescription = description ?? identity.description;
431
431
  /* A full-bleed opener (a full-screen hero, a full-bleed banner) as the page's first
432
432
  block sits flush under the header, not below the main's top padding. */
433
433
  .site-main:has(> .b-hero[data-size='screen']:first-child),
434
+ .site-main:has(> .b-hero[data-full-bleed='true']:first-child),
434
435
  .site-main:has(> .b-banner[data-full-bleed='true']:first-child) {
435
436
  padding-block-start: 0;
436
437
  }
@@ -69,3 +69,16 @@ export function inlineMarkdown(text: string | undefined | null): string {
69
69
  out += inlineFormat(escapeHtml(text.slice(last)));
70
70
  return out;
71
71
  }
72
+
73
+ /** The words of formatted copy with the formatting gone (a link becomes its label): for
74
+ * places that must be plain text, like a page's meta description. */
75
+ export function plainText(text: string | undefined | null): string {
76
+ if (!text) return '';
77
+ return text
78
+ .replace(LINK_RE, '$1')
79
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
80
+ .replace(/(^|[^*\w])\*(?![\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1$2')
81
+ .replace(/(^|[^\w])_(?![\s_])([^_]*?[^\s_])_(?!\w)/g, '$1$2')
82
+ .replace(/\s*\n\s*/g, ' ')
83
+ .trim();
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "type": "module",
5
5
  "description": "Ferst Core \u2014 the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
6
6
  "license": "BUSL-1.1",
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { plainText } from '../../lib/inline-markdown';
2
3
  /**
3
4
  * The calendar page (`/calendar`) — INJECTED by the ferst() integration. Consent-
4
5
  * gated by CalendarEmbed (nothing loads from Google until the visitor opts in).
@@ -12,7 +13,7 @@ import { getCalendarSettings } from '../../lib/calendar';
12
13
  const cal = await getCalendarSettings();
13
14
  ---
14
15
 
15
- <Base title={cal.title} description={cal.intro || undefined}>
16
+ <Base title={cal.title} description={plainText(cal.intro) || undefined}>
16
17
  <CalendarEmbed
17
18
  title={cal.title}
18
19
  intro={cal.intro}