ferst-core 0.1.0 → 0.2.1

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 (70) hide show
  1. package/LICENSE +102 -201
  2. package/LICENSE-Apache-2.0 +201 -0
  3. package/NOTICE +24 -5
  4. package/README.md +89 -73
  5. package/bin/check-thin.mjs +74 -0
  6. package/components/BlockRenderer.astro +89 -0
  7. package/components/Button.astro +85 -33
  8. package/components/CalendarEmbed.astro +188 -0
  9. package/components/Card.astro +7 -17
  10. package/components/Icon.astro +35 -0
  11. package/components/PostArticle.astro +133 -0
  12. package/components/PostCard.astro +93 -225
  13. package/components/SiteFooter.astro +2 -2
  14. package/components/SiteHeader.astro +104 -51
  15. package/components/blocks/Badge.astro +29 -0
  16. package/components/blocks/ButtonBlock.astro +13 -0
  17. package/components/blocks/Divider.astro +12 -0
  18. package/components/blocks/Grid.astro +48 -0
  19. package/components/blocks/Heading.astro +42 -0
  20. package/components/blocks/ImageBlock.astro +80 -0
  21. package/components/blocks/List.astro +78 -0
  22. package/components/blocks/Prose.astro +29 -0
  23. package/components/blocks/Quote.astro +32 -0
  24. package/components/blocks/Section.astro +52 -0
  25. package/components/blocks/Spacer.astro +20 -0
  26. package/components/blocks/Stack.astro +30 -0
  27. package/components/blocks/Stat.astro +30 -0
  28. package/components/recipes/Accordion.astro +84 -0
  29. package/components/recipes/Announcement.astro +74 -0
  30. package/components/recipes/Banner.astro +134 -0
  31. package/components/recipes/Bento.astro +138 -0
  32. package/components/recipes/ContactForm.astro +172 -0
  33. package/components/recipes/Cta.astro +83 -0
  34. package/components/recipes/Faq.astro +80 -0
  35. package/components/recipes/FeatureCards.astro +105 -0
  36. package/components/recipes/Gallery.astro +55 -0
  37. package/components/recipes/Hero.astro +191 -0
  38. package/components/recipes/Logos.astro +77 -0
  39. package/components/recipes/Marquee.astro +75 -0
  40. package/components/recipes/Pricing.astro +149 -0
  41. package/components/recipes/Stats.astro +124 -0
  42. package/components/recipes/Steps.astro +120 -0
  43. package/components/recipes/Tabs.astro +133 -0
  44. package/components/recipes/Testimonial.astro +66 -0
  45. package/components/recipes/Tiles.astro +248 -0
  46. package/content/blocks.ts +534 -0
  47. package/content/calendar.ts +40 -0
  48. package/content/collections.ts +25 -14
  49. package/content/posts.ts +89 -0
  50. package/content/schemas.ts +58 -123
  51. package/layouts/Base.astro +30 -56
  52. package/lib/calendar.ts +12 -0
  53. package/lib/icons.ts +64 -0
  54. package/lib/pages.ts +42 -0
  55. package/lib/posts.ts +93 -0
  56. package/lib/siteSettings.ts +18 -36
  57. package/lib/themeTokens.ts +163 -0
  58. package/package.json +61 -48
  59. package/styles/theme.css +42 -0
  60. package/components/DocLayout.astro +0 -292
  61. package/components/GroupCard.astro +0 -164
  62. package/components/LatestPostList.astro +0 -64
  63. package/components/PaginationNav.astro +0 -81
  64. package/components/PostsToolbar.astro +0 -83
  65. package/content/filters.ts +0 -107
  66. package/lib/mailLinks.ts +0 -13
  67. package/lib/mapsLink.ts +0 -7
  68. package/utils/excerpt.ts +0 -56
  69. package/utils/formatDate.ts +0 -22
  70. package/utils/freshness.ts +0 -5
@@ -15,8 +15,8 @@ import type { NavLink, NavGroup } from '../content/schemas';
15
15
  interface Props {
16
16
  path: string;
17
17
  headerClass?: string;
18
- primaryLogoLight: string;
19
- primaryLogoDark: string;
18
+ primaryLogoLight?: string;
19
+ primaryLogoDark?: string;
20
20
  brandAlt: string;
21
21
  primaryNav: NavLink[];
22
22
  /** Optional dropdown / mega-menu group. Client-configured — the core ships none. */
@@ -56,28 +56,34 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
56
56
  -->
57
57
  <header class:list={['site-header', headerClass]} transition:persist transition:name="site-header" transition:animate="none">
58
58
  <a href="/" class="brand">
59
- <img
60
- id="primary-logo"
61
- src={primaryLogoLight}
62
- data-src-light={primaryLogoLight}
63
- data-src-dark={primaryLogoDark}
64
- alt={brandAlt}
65
- class="primary-logo"
66
- width="545"
67
- height="151"
68
- loading="eager"
69
- fetchpriority="high"
70
- decoding="sync"
71
- />
59
+ {primaryLogoLight ? (
60
+ <img
61
+ id="primary-logo"
62
+ src={primaryLogoLight}
63
+ data-src-light={primaryLogoLight}
64
+ data-src-dark={primaryLogoDark ?? primaryLogoLight}
65
+ alt={brandAlt}
66
+ class="primary-logo"
67
+ width="545"
68
+ height="151"
69
+ loading="eager"
70
+ fetchpriority="high"
71
+ decoding="sync"
72
+ />
73
+ ) : (
74
+ <span class="brand-wordmark">{brandAlt}</span>
75
+ )}
72
76
  </a>
73
- <script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
74
- (function () {
75
- var img = document.getElementById('primary-logo');
76
- if (!img) return;
77
- var t = document.documentElement.dataset.theme;
78
- img.src = t === 'dark' ? primaryLogoDark : primaryLogoLight;
79
- })();
80
- </script>
77
+ {primaryLogoLight && (
78
+ <script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
79
+ (function () {
80
+ var img = document.getElementById('primary-logo');
81
+ if (!img) return;
82
+ var t = document.documentElement.dataset.theme;
83
+ img.src = t === 'dark' ? (primaryLogoDark || primaryLogoLight) : primaryLogoLight;
84
+ })();
85
+ </script>
86
+ )}
81
87
 
82
88
  <nav class="site-nav" id="site-nav" aria-label="Main navigation">
83
89
  {dropdown && (
@@ -196,6 +202,21 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
196
202
  width: auto;
197
203
  }
198
204
 
205
+ /* Text wordmark fallback — shown when a client has configured no logo image
206
+ (var(--fg) auto-switches for dark mode via theme.css). */
207
+ .brand-wordmark {
208
+ font-family: var(--font-heading);
209
+ font-weight: 700;
210
+ font-size: 1.25rem;
211
+ line-height: 1;
212
+ color: var(--fg);
213
+ letter-spacing: 0.01em;
214
+ white-space: nowrap;
215
+ }
216
+ @media (min-width: 64em) {
217
+ .brand-wordmark { font-size: 1.5rem; }
218
+ }
219
+
199
220
  /* ── Header ── */
200
221
  /* Logo + header: mobile-first — compact until desktop ≥64em scales up (matches button/theme sizing). */
201
222
  .site-header {
@@ -218,6 +239,31 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
218
239
  }
219
240
  }
220
241
  [data-theme="dark"] .site-header { background: var(--dark-bg); }
242
+
243
+ /* Sticky "shrink on scroll": the header condenses and gains a shadow once the
244
+ page is scrolled, then expands again at the top. Purely additive; safe with
245
+ the sticky positioning. Motion honoured. */
246
+ .site-header {
247
+ transition: height 0.25s ease, box-shadow 0.25s ease;
248
+ }
249
+ .site-header.is-compact {
250
+ box-shadow: 0 2px 14px rgba(15, 23, 42, 0.10);
251
+ height: 50px;
252
+ }
253
+ @media (min-width: 64em) {
254
+ .site-header.is-compact { height: 54px; }
255
+ }
256
+ .site-header .primary-logo,
257
+ .site-header .brand-wordmark {
258
+ transition: height 0.25s ease, font-size 0.25s ease;
259
+ }
260
+ .site-header.is-compact .primary-logo { height: 38px; }
261
+ .site-header.is-compact .brand-wordmark { font-size: 1.15rem; }
262
+ @media (prefers-reduced-motion: reduce) {
263
+ .site-header,
264
+ .site-header .primary-logo,
265
+ .site-header .brand-wordmark { transition: none; }
266
+ }
221
267
  .brand {
222
268
  text-decoration: none;
223
269
  display: flex;
@@ -265,7 +311,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
265
311
  padding: 14px 0;
266
312
  color: var(--accent);
267
313
  text-decoration: none;
268
- font-family: 'Inter', 'Lato', sans-serif;
314
+ font-family: var(--font-body);
269
315
  font-size: 0.875rem;
270
316
  font-weight: 500;
271
317
  letter-spacing: 0.1em;
@@ -316,10 +362,10 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
316
362
  white-space: nowrap;
317
363
  }
318
364
  .nav-cta:hover,
319
- .nav-cta.active { background: #1A2A1A; color: var(--text-light); border: none; }
320
- [data-theme="dark"] .nav-cta { background: var(--gold); color: var(--fg); border: none; }
365
+ .nav-cta.active { background: color-mix(in srgb, var(--gold) 85%, #000); color: var(--text-light); border: none; }
366
+ [data-theme="dark"] .nav-cta { background: var(--gold); color: var(--dark-bg); border: none; }
321
367
  [data-theme="dark"] .nav-cta:hover,
322
- [data-theme="dark"] .nav-cta.active { background: var(--text-footer-link); color: var(--fg); border: none; }
368
+ [data-theme="dark"] .nav-cta.active { background: color-mix(in srgb, var(--gold) 85%, #fff); color: var(--dark-bg); border: none; }
323
369
 
324
370
  .nav-mobile-theme .nav-cta--drawer {
325
371
  display: none;
@@ -389,7 +435,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
389
435
  }
390
436
  .theme-icon--sun { opacity: 1; }
391
437
  [data-theme="dark"] .theme-icon--sun { opacity: 0.5; }
392
- [data-theme="dark"] .theme-icon--moon { opacity: 1; color: #2A1F12; }
438
+ [data-theme="dark"] .theme-icon--moon { opacity: 1; color: var(--dark-bg); }
393
439
 
394
440
  /* Mobile theme toggle row — hidden on desktop */
395
441
  .nav-mobile-theme { display: none; }
@@ -416,7 +462,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
416
462
  position: absolute;
417
463
  width: 22px;
418
464
  height: 2px;
419
- background: #2A3A2A;
465
+ background: var(--fg);
420
466
  border-radius: 2px;
421
467
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease, top 0.3s cubic-bezier(0.4, 0, 0.2, 1);
422
468
  transform-origin: center;
@@ -501,7 +547,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
501
547
  margin: 0;
502
548
  font-size: 1rem;
503
549
  color: var(--accent);
504
- font-family: 'Poppins', sans-serif;
550
+ font-family: var(--font-heading);
505
551
  font-weight: 600;
506
552
  letter-spacing: 0.04em;
507
553
  }
@@ -567,7 +613,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
567
613
  font-size: 0.875rem;
568
614
  font-weight: 600;
569
615
  color: var(--accent);
570
- font-family: 'Inter', sans-serif;
616
+ font-family: var(--font-body);
571
617
  line-height: 1.3;
572
618
  transition: color 0.15s;
573
619
  }
@@ -576,14 +622,14 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
576
622
  font-size: 0.75rem;
577
623
  color: var(--accent);
578
624
  font-weight: 500;
579
- font-family: 'Inter', sans-serif;
625
+ font-family: var(--font-body);
580
626
  line-height: 1.4;
581
627
  }
582
628
  .mega-about-sub {
583
629
  font-size: 0.8rem;
584
630
  color: var(--accent);
585
631
  font-weight: 500;
586
- font-family: 'Inter', sans-serif;
632
+ font-family: var(--font-body);
587
633
  }
588
634
  [data-theme="dark"] .mega-about-desc,
589
635
  [data-theme="dark"] .mega-about-sub {
@@ -626,8 +672,8 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
626
672
  left: 50%;
627
673
  transform: translateX(-50%);
628
674
  min-width: 280px;
629
- background: #1e2d1c;
630
- border: 1px solid #4A6741;
675
+ background: var(--bg-surface);
676
+ border: 1px solid var(--border);
631
677
  border-top: 2px solid var(--gold);
632
678
  border-radius: 0 0 10px 10px;
633
679
  box-shadow: 0 12px 32px rgba(0,0,0,0.35);
@@ -643,21 +689,21 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
643
689
  align-items: center;
644
690
  gap: 12px;
645
691
  padding: 0.65rem 1.1rem;
646
- color: #D9CDB8;
692
+ color: var(--muted);
647
693
  text-decoration: none;
648
694
  transition: background 0.12s, color 0.12s;
649
695
  border-radius: 0;
650
696
  }
651
697
  .nav-dropdown li a:hover {
652
698
  background: rgba(200,151,58,0.1);
653
- color: #FAF6EF;
699
+ color: var(--fg);
654
700
  }
655
701
  .nav-dropdown li a:focus-visible {
656
702
  outline: 2px solid var(--gold);
657
703
  outline-offset: -2px;
658
704
  }
659
705
  .nav-dropdown li a:hover .drop-title { color: var(--gold); }
660
- .nav-dropdown li a.active { color: #FAF6EF; }
706
+ .nav-dropdown li a.active { color: var(--fg); }
661
707
  .nav-dropdown li a.active .drop-title { color: var(--gold); }
662
708
  .nav-dropdown li a.active .drop-icon { background: rgba(200,151,58,0.25); }
663
709
  .nav-dropdown li + li {
@@ -683,16 +729,16 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
683
729
  }
684
730
 
685
731
  .drop-title {
686
- font-family: 'Inter', sans-serif;
732
+ font-family: var(--font-body);
687
733
  font-size: 0.8125rem;
688
734
  font-weight: 600;
689
- color: #C8D4C2;
735
+ color: var(--text-light);
690
736
  line-height: 1.3;
691
737
  transition: color 0.12s;
692
738
  }
693
739
 
694
740
  .drop-desc {
695
- font-family: 'Inter', sans-serif;
741
+ font-family: var(--font-body);
696
742
  font-size: 0.6875rem;
697
743
  color: var(--text-subtle);
698
744
  line-height: 1.3;
@@ -766,11 +812,11 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
766
812
  padding: 1rem 3rem;
767
813
  font-size: 0.9375rem;
768
814
  font-weight: 300;
769
- font-family: 'Poppins', sans-serif;
815
+ font-family: var(--font-heading);
770
816
  border-radius: 0;
771
817
  text-align: left;
772
818
  width: 100%;
773
- color: #2A3A2A;
819
+ color: var(--fg);
774
820
  transition: color 0.18s ease;
775
821
  }
776
822
  .nav-link::before {
@@ -785,9 +831,9 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
785
831
  /* Last link (Calendar) sits directly above .nav-mobile-theme's own
786
832
  border-top — drop its trailing divider so the two don't double up. */
787
833
  .site-nav > .nav-link:last-of-type::before { display: none; }
788
- .nav-link:hover { color: #C8973A; background: none; }
834
+ .nav-link:hover { color: var(--gold); background: none; }
789
835
  .nav-link::after { display: none; }
790
- .nav-link.active { color: #C8973A; font-weight: 400; }
836
+ .nav-link.active { color: var(--gold); font-weight: 400; }
791
837
 
792
838
  .nav-mobile-theme {
793
839
  display: flex;
@@ -831,19 +877,19 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
831
877
  gap: 10px;
832
878
  justify-content: flex-start;
833
879
  font-size: 0.8rem;
834
- color: #2A3A2A;
880
+ color: var(--fg);
835
881
  transition: color 0.18s ease;
836
882
  }
837
- .nav-dropdown li a:hover { color: #C8973A; background: none; }
883
+ .nav-dropdown li a:hover { color: var(--gold); background: none; }
838
884
  .drop-icon { width: 24px; height: 24px; flex-shrink: 0; transition: color 0.18s ease; }
839
- .drop-title { font-size: 0.75rem; color: #2A3A2A; transition: color 0.18s ease; }
885
+ .drop-title { font-size: 0.75rem; color: var(--fg); transition: color 0.18s ease; }
840
886
  [data-theme="dark"] .nav-dropdown li a { color: rgba(250,246,239,0.75); }
841
887
  [data-theme="dark"] .nav-dropdown li a:hover,
842
- [data-theme="dark"] .nav-dropdown li a:active { color: #C8973A; }
888
+ [data-theme="dark"] .nav-dropdown li a:active { color: var(--gold); }
843
889
  [data-theme="dark"] .nav-dropdown li a:hover .drop-title,
844
- [data-theme="dark"] .nav-dropdown li a:active .drop-title { color: #C8973A; }
890
+ [data-theme="dark"] .nav-dropdown li a:active .drop-title { color: var(--gold); }
845
891
  [data-theme="dark"] .nav-dropdown li a:hover .drop-icon,
846
- [data-theme="dark"] .nav-dropdown li a:active .drop-icon { color: #C8973A; }
892
+ [data-theme="dark"] .nav-dropdown li a:active .drop-icon { color: var(--gold); }
847
893
  [data-theme="dark"] .drop-title { color: rgba(250,246,239,0.9); }
848
894
  .drop-desc { display: none; }
849
895
  .nav-dropdown li + li { border-top: 1px solid rgba(255,255,255,0.04); }
@@ -937,6 +983,13 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
937
983
 
938
984
  const siteHeader = document.querySelector('.site-header') as HTMLElement | null;
939
985
 
986
+ // Shrink-on-scroll: condense the header once the page is scrolled.
987
+ if (siteHeader) {
988
+ const onScrollCompact = () => siteHeader.classList.toggle('is-compact', window.scrollY > 40);
989
+ onScrollCompact();
990
+ window.addEventListener('scroll', onScrollCompact, { passive: true });
991
+ }
992
+
940
993
  function openMega() {
941
994
  if (!megaNav || !aboutTrigger) return;
942
995
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
@@ -0,0 +1,29 @@
1
+ ---
2
+ interface Props {
3
+ text: string;
4
+ tone?: 'neutral' | 'accent';
5
+ }
6
+ const { text, tone = 'accent' } = Astro.props;
7
+ ---
8
+
9
+ <span class="b-badge" data-tone={tone}>{text}</span>
10
+
11
+ <style>
12
+ .b-badge {
13
+ display: inline-block;
14
+ font-size: 0.75rem;
15
+ font-weight: 600;
16
+ letter-spacing: 0.06em;
17
+ text-transform: uppercase;
18
+ padding: 0.2rem 0.6rem;
19
+ border-radius: 999px;
20
+ }
21
+ .b-badge[data-tone='accent'] {
22
+ color: var(--gold);
23
+ background: var(--gold-light);
24
+ }
25
+ .b-badge[data-tone='neutral'] {
26
+ color: var(--muted);
27
+ background: var(--bg-section);
28
+ }
29
+ </style>
@@ -0,0 +1,13 @@
1
+ ---
2
+ /** Block wrapper over the shared Button primitive (single source). */
3
+ import Button from '../Button.astro';
4
+
5
+ interface Props {
6
+ label: string;
7
+ href: string;
8
+ variant?: 'primary' | 'secondary';
9
+ }
10
+ const { label, href, variant = 'primary' } = Astro.props;
11
+ ---
12
+
13
+ <Button href={href} variant={variant}>{label}</Button>
@@ -0,0 +1,12 @@
1
+ ---
2
+ ---
3
+
4
+ <hr class="b-divider" />
5
+
6
+ <style>
7
+ .b-divider {
8
+ border: 0;
9
+ border-top: 1px solid var(--border);
10
+ margin: 0;
11
+ }
12
+ </style>
@@ -0,0 +1,48 @@
1
+ ---
2
+ /** A layout container: places child blocks in a responsive N-column grid
3
+ * (one column on mobile, N from tablet up). */
4
+ import BlockRenderer from '../BlockRenderer.astro';
5
+
6
+ interface Props {
7
+ columns?: 2 | 3 | 4;
8
+ gap?: 'sm' | 'md' | 'lg';
9
+ blocks?: any[];
10
+ }
11
+ const { columns = 2, gap = 'md', blocks = [] } = Astro.props;
12
+ ---
13
+
14
+ <div class="b-grid" data-cols={columns} data-gap={gap}>
15
+ <BlockRenderer blocks={blocks} />
16
+ </div>
17
+
18
+ <style>
19
+ .b-grid {
20
+ display: grid;
21
+ grid-template-columns: 1fr; /* mobile-first: single column */
22
+ }
23
+ .b-grid[data-gap='sm'] {
24
+ gap: 0.75rem;
25
+ }
26
+ .b-grid[data-gap='md'] {
27
+ gap: 1.5rem;
28
+ }
29
+ .b-grid[data-gap='lg'] {
30
+ gap: 3rem;
31
+ }
32
+ @media (min-width: 48em) {
33
+ .b-grid[data-cols='2'] {
34
+ grid-template-columns: repeat(2, 1fr);
35
+ }
36
+ .b-grid[data-cols='3'] {
37
+ grid-template-columns: repeat(3, 1fr);
38
+ }
39
+ .b-grid[data-cols='4'] {
40
+ grid-template-columns: repeat(2, 1fr);
41
+ }
42
+ }
43
+ @media (min-width: 64em) {
44
+ .b-grid[data-cols='4'] {
45
+ grid-template-columns: repeat(4, 1fr);
46
+ }
47
+ }
48
+ </style>
@@ -0,0 +1,42 @@
1
+ ---
2
+ interface Props {
3
+ text: string;
4
+ level?: number;
5
+ align?: 'start' | 'center' | 'end';
6
+ gradient?: boolean;
7
+ }
8
+ const { text, level = 2, align = 'start', gradient = false } = Astro.props;
9
+ const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
10
+ ---
11
+
12
+ <Tag class="b-heading" data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
13
+
14
+ <style>
15
+ /* A heading owns its own alignment via the `align` prop — it never depends on
16
+ an ancestor rule. (Base no longer force-centres headings, so a recipe that
17
+ asks for `start` really is left-aligned, and `center` really is centred,
18
+ wherever the block is placed — including AI-authored block JSON.) */
19
+ .b-heading {
20
+ color: var(--fg);
21
+ margin: 0;
22
+ text-wrap: balance;
23
+ line-height: 1.2;
24
+ text-align: start;
25
+ }
26
+ .b-heading[data-align='center'] {
27
+ text-align: center;
28
+ }
29
+ .b-heading[data-align='end'] {
30
+ text-align: end;
31
+ }
32
+ /* Gradient text — only clip to text where supported, so it never renders as
33
+ invisible/transparent on older browsers (it stays solid ink there). */
34
+ @supports ((-webkit-background-clip: text) or (background-clip: text)) {
35
+ .b-heading[data-gradient='true'] {
36
+ background-image: linear-gradient(120deg, var(--gold), var(--accent-2, var(--gold)));
37
+ -webkit-background-clip: text;
38
+ background-clip: text;
39
+ color: transparent;
40
+ }
41
+ }
42
+ </style>
@@ -0,0 +1,80 @@
1
+ ---
2
+ /**
3
+ * A basic image block with a small set of CANONICAL framing options — corners
4
+ * (none / soft / round), a drop shadow, an outline stroke, an aspect ratio, and
5
+ * an optional soft scroll-in reveal. These are clear, named choices the AI or a
6
+ * client picks from (not free-form CSS); corners/shadow/stroke read the global
7
+ * surface tokens, so images stay consistent with every other surface.
8
+ * The build-time optimisation pipeline (docs/IMAGE-PIPELINE.md) is a later
9
+ * upgrade — the schema stays the same.
10
+ */
11
+ interface Props {
12
+ src: string;
13
+ alt?: string;
14
+ caption?: string;
15
+ radius?: 'none' | 'soft' | 'round';
16
+ shadow?: boolean;
17
+ border?: boolean;
18
+ ratio?: 'auto' | 'square' | '4/3' | '3/2' | '16/9' | '21/9';
19
+ reveal?: boolean;
20
+ }
21
+ const { src, alt = '', caption, radius = 'soft', shadow = false, border = false, ratio = 'auto', reveal = false } = Astro.props;
22
+ const ratioValue = ratio === 'auto' ? null : ratio === 'square' ? '1 / 1' : ratio.replace('/', ' / ');
23
+ ---
24
+
25
+ <figure
26
+ class="b-image"
27
+ data-radius={radius}
28
+ data-shadow={shadow ? 'true' : 'false'}
29
+ data-border={border ? 'true' : 'false'}
30
+ data-reveal={reveal ? 'true' : 'false'}
31
+ >
32
+ <img
33
+ src={src}
34
+ alt={alt}
35
+ loading="lazy"
36
+ decoding="async"
37
+ style={ratioValue ? `aspect-ratio: ${ratioValue}` : undefined}
38
+ />
39
+ {caption && <figcaption>{caption}</figcaption>}
40
+ </figure>
41
+
42
+ <style>
43
+ .b-image { margin: 0; }
44
+ .b-image img {
45
+ display: block;
46
+ width: 100%;
47
+ height: auto;
48
+ object-fit: cover; /* only bites when an aspect-ratio is set */
49
+ }
50
+ /* Corners */
51
+ .b-image[data-radius='none'] img { border-radius: 0; }
52
+ .b-image[data-radius='soft'] img { border-radius: var(--radius); }
53
+ .b-image[data-radius='round'] img { border-radius: var(--radius-pill); }
54
+ /* Drop shadow + outline stroke, from the global surface tokens */
55
+ .b-image[data-shadow='true'] img { box-shadow: var(--shadow-md); }
56
+ .b-image[data-border='true'] img { border: var(--card-border); }
57
+
58
+ .b-image figcaption {
59
+ color: var(--muted);
60
+ font-size: 0.875rem;
61
+ margin-top: 0.5rem;
62
+ text-align: center;
63
+ }
64
+
65
+ /* Soft scroll-in reveal — native scroll-driven, so no JS/observer. Fully
66
+ visible where unsupported or under reduced-motion. */
67
+ @keyframes b-image-reveal {
68
+ from { clip-path: inset(42% 0 42% 0); opacity: 0.35; }
69
+ to { clip-path: inset(0 0 0 0); opacity: 1; }
70
+ }
71
+ @media (prefers-reduced-motion: no-preference) {
72
+ @supports (animation-timeline: view()) {
73
+ .b-image[data-reveal='true'] img {
74
+ animation: b-image-reveal linear both;
75
+ animation-timeline: view();
76
+ animation-range: entry 5% cover 35%;
77
+ }
78
+ }
79
+ }
80
+ </style>
@@ -0,0 +1,78 @@
1
+ ---
2
+ /** List — a leaf primitive: a bulleted, numbered, or checked list of lines.
3
+ * `check` uses the shared check icon in the brand accent. Token-driven. */
4
+ import Icon from '../Icon.astro';
5
+
6
+ interface Props {
7
+ style?: 'bullet' | 'number' | 'check';
8
+ items?: string[];
9
+ }
10
+ const { style = 'bullet', items = [] } = Astro.props;
11
+ const Tag = style === 'number' ? 'ol' : 'ul';
12
+ ---
13
+
14
+ <Tag class:list={['b-list', `b-list--${style}`]}>
15
+ {items.map((item) => (
16
+ <li class="b-list__item">
17
+ {style === 'check' && <span class="b-list__mark" aria-hidden="true"><Icon name="check" size={18} /></span>}
18
+ <span>{item}</span>
19
+ </li>
20
+ ))}
21
+ </Tag>
22
+
23
+ <style>
24
+ .b-list {
25
+ margin: 0;
26
+ padding: 0;
27
+ display: flex;
28
+ flex-direction: column;
29
+ gap: 0.6rem;
30
+ color: var(--muted);
31
+ line-height: 1.6;
32
+ }
33
+ .b-list__item {
34
+ display: flex;
35
+ gap: 0.6rem;
36
+ align-items: flex-start;
37
+ }
38
+ /* bullet + number keep native markers, but as flex items we render our own */
39
+ .b-list--bullet {
40
+ list-style: none;
41
+ }
42
+ .b-list--bullet .b-list__item::before {
43
+ content: '';
44
+ flex-shrink: 0;
45
+ width: 0.45rem;
46
+ height: 0.45rem;
47
+ margin-top: 0.6rem;
48
+ border-radius: 999px;
49
+ background: var(--gold);
50
+ }
51
+ .b-list--number {
52
+ list-style: none;
53
+ counter-reset: b-list;
54
+ }
55
+ .b-list--number .b-list__item {
56
+ counter-increment: b-list;
57
+ }
58
+ .b-list--number .b-list__item::before {
59
+ content: counter(b-list);
60
+ flex-shrink: 0;
61
+ min-width: 1.5rem;
62
+ height: 1.5rem;
63
+ display: inline-flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ border-radius: 999px;
67
+ background: var(--gold-light);
68
+ color: var(--gold);
69
+ font-size: 0.8125rem;
70
+ font-weight: 700;
71
+ }
72
+ .b-list__mark {
73
+ flex-shrink: 0;
74
+ display: inline-flex;
75
+ color: var(--gold);
76
+ margin-top: 0.15rem;
77
+ }
78
+ </style>
@@ -0,0 +1,29 @@
1
+ ---
2
+ interface Props {
3
+ text: string;
4
+ }
5
+ const { text } = Astro.props;
6
+ // Blank lines separate paragraphs. (Markdown rendering upgrades this later.)
7
+ const paragraphs = text
8
+ .split(/\n\s*\n/)
9
+ .map((p) => p.trim())
10
+ .filter(Boolean);
11
+ ---
12
+
13
+ <div class="b-prose">
14
+ {paragraphs.map((p) => <p>{p}</p>)}
15
+ </div>
16
+
17
+ <style>
18
+ .b-prose {
19
+ color: var(--fg);
20
+ }
21
+ .b-prose p {
22
+ margin: 0 0 1rem;
23
+ line-height: 1.7;
24
+ max-width: var(--max);
25
+ }
26
+ .b-prose p:last-child {
27
+ margin-bottom: 0;
28
+ }
29
+ </style>
@@ -0,0 +1,32 @@
1
+ ---
2
+ interface Props {
3
+ text: string;
4
+ cite?: string;
5
+ }
6
+ const { text, cite } = Astro.props;
7
+ ---
8
+
9
+ <figure class="b-quote">
10
+ <blockquote>{text}</blockquote>
11
+ {cite && <figcaption>— {cite}</figcaption>}
12
+ </figure>
13
+
14
+ <style>
15
+ .b-quote {
16
+ margin: 0;
17
+ border-inline-start: 3px solid var(--gold);
18
+ padding-inline-start: 1rem;
19
+ }
20
+ .b-quote blockquote {
21
+ margin: 0;
22
+ color: var(--fg);
23
+ font-style: italic;
24
+ font-size: 1.125rem;
25
+ line-height: 1.6;
26
+ }
27
+ .b-quote figcaption {
28
+ color: var(--muted);
29
+ margin-top: 0.5rem;
30
+ font-size: 0.9375rem;
31
+ }
32
+ </style>