ferst-core 0.1.0 → 0.2.0

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 +85 -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
@@ -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>
@@ -0,0 +1,52 @@
1
+ ---
2
+ /**
3
+ * A layout container: a full-width band with an optional background and a
4
+ * width-constrained inner column, holding child blocks (rendered recursively
5
+ * through BlockRenderer). The first composition primitive — how leaf blocks
6
+ * become a laid-out page.
7
+ */
8
+ import BlockRenderer from '../BlockRenderer.astro';
9
+
10
+ interface Props {
11
+ background?: 'none' | 'muted' | 'accent';
12
+ width?: 'normal' | 'wide' | 'full';
13
+ blocks?: any[];
14
+ }
15
+ const { background = 'none', width = 'normal', blocks = [] } = Astro.props;
16
+ ---
17
+
18
+ <section class="b-section" data-bg={background}>
19
+ <div class="b-section__inner" data-width={width}>
20
+ <BlockRenderer blocks={blocks} />
21
+ </div>
22
+ </section>
23
+
24
+ <style>
25
+ .b-section {
26
+ width: 100%;
27
+ }
28
+ .b-section[data-bg='muted'] {
29
+ background: var(--bg-section);
30
+ }
31
+ .b-section[data-bg='accent'] {
32
+ background: var(--gold-light);
33
+ }
34
+ .b-section__inner {
35
+ margin-inline: auto;
36
+ padding-inline: 1rem;
37
+ padding-block: 2.5rem;
38
+ }
39
+ .b-section__inner[data-width='normal'] {
40
+ max-width: var(--max);
41
+ }
42
+ .b-section__inner[data-width='wide'] {
43
+ max-width: 72rem;
44
+ }
45
+ .b-section__inner[data-width='full'] {
46
+ max-width: none;
47
+ }
48
+ /* vertical rhythm between direct child blocks */
49
+ .b-section__inner > :global(* + *) {
50
+ margin-block-start: 1.5rem;
51
+ }
52
+ </style>
@@ -0,0 +1,20 @@
1
+ ---
2
+ interface Props {
3
+ size?: 'sm' | 'md' | 'lg';
4
+ }
5
+ const { size = 'md' } = Astro.props;
6
+ ---
7
+
8
+ <div class="b-spacer" data-size={size} aria-hidden="true"></div>
9
+
10
+ <style>
11
+ .b-spacer[data-size='sm'] {
12
+ height: 1rem;
13
+ }
14
+ .b-spacer[data-size='md'] {
15
+ height: 2rem;
16
+ }
17
+ .b-spacer[data-size='lg'] {
18
+ height: 4rem;
19
+ }
20
+ </style>
@@ -0,0 +1,30 @@
1
+ ---
2
+ /** A layout container: stacks child blocks vertically with a consistent gap. */
3
+ import BlockRenderer from '../BlockRenderer.astro';
4
+
5
+ interface Props {
6
+ gap?: 'sm' | 'md' | 'lg';
7
+ blocks?: any[];
8
+ }
9
+ const { gap = 'md', blocks = [] } = Astro.props;
10
+ ---
11
+
12
+ <div class="b-stack" data-gap={gap}>
13
+ <BlockRenderer blocks={blocks} />
14
+ </div>
15
+
16
+ <style>
17
+ .b-stack {
18
+ display: flex;
19
+ flex-direction: column;
20
+ }
21
+ .b-stack[data-gap='sm'] {
22
+ gap: 0.75rem;
23
+ }
24
+ .b-stack[data-gap='md'] {
25
+ gap: 1.5rem;
26
+ }
27
+ .b-stack[data-gap='lg'] {
28
+ gap: 3rem;
29
+ }
30
+ </style>
@@ -0,0 +1,30 @@
1
+ ---
2
+ interface Props {
3
+ value: string;
4
+ label: string;
5
+ }
6
+ const { value, label } = Astro.props;
7
+ ---
8
+
9
+ <div class="b-stat">
10
+ <div class="b-stat__value">{value}</div>
11
+ <div class="b-stat__label">{label}</div>
12
+ </div>
13
+
14
+ <style>
15
+ .b-stat {
16
+ text-align: center;
17
+ }
18
+ .b-stat__value {
19
+ font-family: var(--font-heading);
20
+ font-weight: 700;
21
+ font-size: 2.5rem;
22
+ line-height: 1;
23
+ color: var(--gold);
24
+ }
25
+ .b-stat__label {
26
+ margin-top: 0.5rem;
27
+ color: var(--muted);
28
+ font-size: 0.9375rem;
29
+ }
30
+ </style>
@@ -0,0 +1,84 @@
1
+ ---
2
+ /** Accordion — a Level-2 recipe: an optional heading over expand/collapse rows
3
+ * (title + body). Generic sibling of FAQ (which is question/answer-flavoured).
4
+ * Native <details>/<summary> — accessible, interactive, zero JavaScript. */
5
+ import Heading from '../blocks/Heading.astro';
6
+
7
+ interface Props {
8
+ title?: string;
9
+ items?: { heading: string; body: string }[];
10
+ }
11
+ const { title, items = [] } = Astro.props;
12
+ ---
13
+
14
+ <section class="b-accordion">
15
+ {title && <Heading text={title} level={2} align="center" />}
16
+ <div class="b-accordion__list">
17
+ {items.map((item) => (
18
+ <details class="b-accordion__item">
19
+ <summary class="b-accordion__head">{item.heading}</summary>
20
+ <div class="b-accordion__body"><p>{item.body}</p></div>
21
+ </details>
22
+ ))}
23
+ </div>
24
+ </section>
25
+
26
+ <style>
27
+ .b-accordion {
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: 1.5rem;
31
+ padding-inline: 1rem;
32
+ }
33
+ .b-accordion__list {
34
+ max-width: var(--max);
35
+ margin-inline: auto;
36
+ width: 100%;
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: 0.5rem;
40
+ }
41
+ .b-accordion__item {
42
+ border: var(--card-border);
43
+ border-radius: var(--radius-sm);
44
+ padding: 0.25rem 1rem;
45
+ background: var(--bg-surface);
46
+ }
47
+ .b-accordion__head {
48
+ cursor: pointer;
49
+ font-weight: 600;
50
+ color: var(--fg);
51
+ padding: 0.85rem 0;
52
+ list-style: none;
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: space-between;
56
+ gap: 0.75rem;
57
+ }
58
+ .b-accordion__head::-webkit-details-marker {
59
+ display: none;
60
+ }
61
+ .b-accordion__head::after {
62
+ content: '+';
63
+ color: var(--gold);
64
+ font-weight: 700;
65
+ font-size: 1.25rem;
66
+ line-height: 1;
67
+ transition: transform 0.2s ease;
68
+ }
69
+ .b-accordion__item[open] .b-accordion__head::after {
70
+ content: '\2013'; /* en dash */
71
+ }
72
+ .b-accordion__head:focus-visible {
73
+ outline: 2px solid var(--gold);
74
+ outline-offset: 2px;
75
+ }
76
+ .b-accordion__body {
77
+ padding: 0 0 0.9rem;
78
+ color: var(--muted);
79
+ line-height: 1.6;
80
+ }
81
+ .b-accordion__body p {
82
+ margin: 0;
83
+ }
84
+ </style>
@@ -0,0 +1,74 @@
1
+ ---
2
+ /** Announcement — a Level-2 recipe: a thin full-width bar (message + optional
3
+ * link), optionally dismissible. Dismissal persists per `id` in localStorage,
4
+ * so a returning visitor doesn't see a bar they've closed. */
5
+ interface Props {
6
+ text: string;
7
+ link?: { label: string; href: string };
8
+ tone?: 'accent' | 'neutral';
9
+ dismissible?: boolean;
10
+ id?: string;
11
+ }
12
+ const { text, link, tone = 'accent', dismissible = true, id = 'default' } = Astro.props;
13
+ const key = `ferst-dismissed-${id}`;
14
+ ---
15
+
16
+ <aside class="b-announce" data-tone={tone} data-key={key} role="region" aria-label="Announcement">
17
+ <p class="b-announce__text">
18
+ {text}
19
+ {link && <a class="b-announce__link" href={link.href}>{link.label} &rarr;</a>}
20
+ </p>
21
+ {dismissible && (
22
+ <button type="button" class="b-announce__close" aria-label="Dismiss announcement">&times;</button>
23
+ )}
24
+ </aside>
25
+
26
+ <style>
27
+ .b-announce {
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ gap: 1rem;
32
+ padding: 0.6rem 1.25rem;
33
+ font-size: 0.9rem;
34
+ text-align: center;
35
+ }
36
+ .b-announce[data-tone='accent'] { background: var(--gold); color: var(--text-light); }
37
+ .b-announce[data-tone='neutral'] { background: var(--bg-section); color: var(--fg); }
38
+ .b-announce__text { margin: 0; line-height: 1.4; }
39
+ .b-announce__link {
40
+ color: inherit;
41
+ font-weight: 700;
42
+ text-decoration: underline;
43
+ text-underline-offset: 2px;
44
+ white-space: nowrap;
45
+ }
46
+ .b-announce[data-tone='neutral'] .b-announce__link { color: var(--gold); }
47
+ .b-announce__close {
48
+ appearance: none;
49
+ background: none;
50
+ border: none;
51
+ color: inherit;
52
+ font-size: 1.4rem;
53
+ line-height: 1;
54
+ cursor: pointer;
55
+ padding: 0 0.25rem;
56
+ opacity: 0.85;
57
+ flex-shrink: 0;
58
+ }
59
+ .b-announce__close:hover { opacity: 1; }
60
+ .b-announce__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
61
+ </style>
62
+
63
+ <script>
64
+ document.querySelectorAll<HTMLElement>('.b-announce').forEach((bar) => {
65
+ const key = bar.dataset.key;
66
+ if (key) {
67
+ try { if (localStorage.getItem(key) === '1') { bar.remove(); return; } } catch (_) {}
68
+ }
69
+ bar.querySelector('.b-announce__close')?.addEventListener('click', () => {
70
+ bar.remove();
71
+ if (key) { try { localStorage.setItem(key, '1'); } catch (_) {} }
72
+ });
73
+ });
74
+ </script>