ferst-core 0.7.0 → 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.
@@ -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>
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import Heading from '../blocks/Heading.astro';
15
15
  import Button from '../Button.astro';
16
- import { videoEmbed } from '../../lib/video';
16
+ import { backgroundEmbed, videoEmbed } from '../../lib/video';
17
17
 
18
18
  interface Props {
19
19
  eyebrow?: string;
@@ -27,17 +27,23 @@ interface Props {
27
27
  background?: 'none' | 'mesh';
28
28
  }
29
29
  const { eyebrow, title, subtitle, cta, image, media, layout = 'centered', size = 'standard', background = 'none' } = Astro.props;
30
+ // A YouTube / Vimeo link plays as an embedded background (after consent); anything else
31
+ // in `video` is a file for a native <video>.
32
+ const embed = media?.video ? backgroundEmbed(media.video) : null;
30
33
  const slides = !media?.video && (media?.images?.length ?? 0) >= 2 ? media!.images! : [];
31
34
  const still = media?.image ?? media?.images?.[0];
32
35
  const isMedia = layout === 'media' && media && (media.video || slides.length || still);
33
- const motion = isMedia && (media?.video || slides.length) ? (media?.video ? 'video' : 'slides') : undefined;
36
+ const motion = isMedia ? (embed ? 'embed' : media?.video ? 'video' : slides.length ? 'slides' : undefined) : undefined;
34
37
  const overlay = (media?.overlay ?? 45) / 100;
35
38
  const isMesh = background === 'mesh' && !isMedia;
36
39
  // The background can lead somewhere — e.g. the full video on YouTube (new tab; the app on phones).
37
- const bgLink = isMedia && media?.link && /^(https?:\/\/|\/)/i.test(media.link) ? media.link : undefined;
40
+ const linkTo = media?.link ?? embed?.watchUrl; // an embedded video links to itself by default
41
+ const bgLink = isMedia && linkTo && /^(https?:\/\/|\/)/i.test(linkTo) ? linkTo : undefined;
38
42
  const provider = bgLink ? videoEmbed(bgLink)?.provider : undefined;
39
43
  const watchLabel = media?.linkLabel || (provider ? `Watch the full video on ${provider}` : 'Watch the full video');
40
- const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-image: url('${still}')` : '';
44
+ // The still: the image (no motion), or an embed's poster until the player may load.
45
+ const stillSrc = embed ? media?.poster ?? still : !media?.video && !slides.length ? still : undefined;
46
+ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
41
47
  ---
42
48
 
43
49
  <section
@@ -51,7 +57,8 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
51
57
  >
52
58
  {isMedia && (
53
59
  <div class="b-hero__bg" aria-hidden="true">
54
- {media?.video ? (
60
+ {embed && <div class="b-hero__embed" data-embed-url={embed.embedUrl} data-provider={embed.provider}></div>}
61
+ {media?.video && !embed ? (
55
62
  <video autoplay muted loop playsinline poster={media.poster} preload="metadata">
56
63
  <source src={media.video} />
57
64
  </video>
@@ -80,7 +87,7 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
80
87
  </a>
81
88
  )}
82
89
  {motion && (
83
- <button type="button" class="b-hero__pause" aria-pressed="false" aria-label="Pause background">
90
+ <button type="button" class="b-hero__pause" aria-pressed="false" aria-label="Pause background" hidden={motion === 'embed'}>
84
91
  <span class="b-hero__pause-icon" aria-hidden="true"></span>
85
92
  </button>
86
93
  )}
@@ -92,13 +99,38 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
92
99
  </section>
93
100
 
94
101
  <script>
95
- // Moving hero backgrounds: pause button, reduced-motion, and the slideshow timer.
102
+ // Moving hero backgrounds: pause button, reduced-motion, the slideshow timer, and an
103
+ // embedded (YouTube / Vimeo) background that loads only once the visitor consents.
104
+ import { hasConsent, onConsentChange } from '../../lib/consent-client';
96
105
  const SLIDE_MS = 6000;
97
106
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
98
107
  for (const hero of document.querySelectorAll<HTMLElement>('.b-hero[data-motion]')) {
99
108
  const button = hero.querySelector<HTMLButtonElement>('.b-hero__pause');
100
109
  const video = hero.querySelector('video');
101
110
  const slides = [...hero.querySelectorAll<HTMLElement>('.b-hero__slide')];
111
+ const embedSlot = hero.querySelector<HTMLElement>('.b-hero__embed');
112
+ let player: HTMLIFrameElement | null = null;
113
+ // Play / pause an embedded player by message, with no provider API script.
114
+ const command = (play: boolean) => {
115
+ if (!player?.contentWindow) return;
116
+ const msg = embedSlot!.dataset.provider === 'Vimeo'
117
+ ? { method: play ? 'play' : 'pause' }
118
+ : { event: 'command', func: play ? 'playVideo' : 'pauseVideo', args: [] };
119
+ player.contentWindow.postMessage(JSON.stringify(msg), new URL(player.src).origin);
120
+ };
121
+ const loadPlayer = () => {
122
+ if (player || !embedSlot || reduce) return; // reduced motion keeps the still
123
+ player = document.createElement('iframe');
124
+ player.src = embedSlot.dataset.embedUrl!;
125
+ player.title = 'Background video';
126
+ player.tabIndex = -1;
127
+ player.setAttribute('aria-hidden', 'true');
128
+ player.allow = 'autoplay; encrypted-media; picture-in-picture';
129
+ player.addEventListener('load', () => window.setTimeout(() => embedSlot.classList.add('is-on'), 600));
130
+ embedSlot.appendChild(player);
131
+ if (button) button.hidden = false;
132
+ sync();
133
+ };
102
134
  let paused = reduce;
103
135
  let visible = true;
104
136
  let index = 0;
@@ -115,6 +147,7 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
115
147
  if (run) void video.play().catch(() => {});
116
148
  else video.pause();
117
149
  }
150
+ if (player) command(run);
118
151
  if (slides.length > 1) {
119
152
  window.clearInterval(timer);
120
153
  timer = run ? window.setInterval(tick, SLIDE_MS) : undefined;
@@ -128,6 +161,10 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
128
161
  button?.addEventListener('click', () => { paused = !paused; sync(); });
129
162
  document.addEventListener('visibilitychange', sync);
130
163
  new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }).observe(hero);
164
+ if (embedSlot) {
165
+ if (hasConsent()) loadPlayer();
166
+ onConsentChange((allowed) => { if (allowed) loadPlayer(); });
167
+ }
131
168
  sync();
132
169
  }
133
170
  </script>
@@ -251,6 +288,33 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
251
288
  height: 100%;
252
289
  object-fit: cover;
253
290
  }
291
+ /* An embedded background player, sized to COVER the hero like object-fit (16:9 kept,
292
+ overflow cropped). Never interactive; fades in once it has started. */
293
+ .b-hero__embed {
294
+ position: absolute;
295
+ inset: 0;
296
+ overflow: hidden;
297
+ pointer-events: none;
298
+ container-type: size;
299
+ opacity: 0;
300
+ transition: opacity 1.2s ease;
301
+ }
302
+ .b-hero__embed.is-on {
303
+ opacity: 1;
304
+ }
305
+ .b-hero__embed :global(iframe) {
306
+ position: absolute;
307
+ top: 50%;
308
+ left: 50%;
309
+ width: max(100cqw, 177.78cqh);
310
+ height: max(100cqh, 56.25cqw);
311
+ max-width: none; /* the global media reset caps iframes at 100%: cover must overflow */
312
+ transform: translate(-50%, -50%);
313
+ border: 0;
314
+ }
315
+ .b-hero__pause[hidden] {
316
+ display: none;
317
+ }
254
318
  .b-hero__slide {
255
319
  position: absolute;
256
320
  inset: 0;