@sarimarcus/content-sites-ui 0.18.0 → 0.20.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.
@@ -0,0 +1,174 @@
1
+ ---
2
+ /**
3
+ * AuthorCard — an author's portrait, role line, name, bio and expertise badges. Three shapes: a whole-card link
4
+ * (`href`), a centred polaroid portrait (`polaroid`), or a plain card with a profile link. `roleLabel` is free text
5
+ * whose meaning is the caller's: a job title on one page, "Curated by" on another.
6
+ */
7
+ import Badge from '../primitives/Badge.astro';
8
+ import LinkArrow from '../primitives/LinkArrow.astro';
9
+ import ArrowLabel from '../primitives/ArrowLabel.astro';
10
+
11
+ interface Props {
12
+ name: string;
13
+ /** Portrait URL, base path already applied. */
14
+ photoSrc: string;
15
+ portraitAlt: string;
16
+ bio: string;
17
+ credentials?: string;
18
+ expertise: string[];
19
+ /** Profile URL used by the plain and polaroid shapes. */
20
+ profileHref: string;
21
+ /** When set, the whole card is this link. */
22
+ href?: string;
23
+ roleLabel: string;
24
+ viewLabel: string;
25
+ /** Link-card footer text, e.g. "12 articles". */
26
+ countLabel?: string;
27
+ /** Clamp the bio to two lines (link-card shape). */
28
+ compact?: boolean;
29
+ headingLevel?: 2 | 3;
30
+ polaroid?: boolean;
31
+ /** Caption under the polaroid portrait. */
32
+ caption?: string;
33
+ class?: string;
34
+ }
35
+
36
+ const {
37
+ name,
38
+ photoSrc,
39
+ portraitAlt,
40
+ bio,
41
+ credentials,
42
+ expertise,
43
+ profileHref,
44
+ href,
45
+ roleLabel,
46
+ viewLabel,
47
+ countLabel,
48
+ compact = false,
49
+ headingLevel = 2,
50
+ polaroid = false,
51
+ caption,
52
+ class: className,
53
+ } = Astro.props;
54
+
55
+ const cardBase = 'rounded-2xl border border-border bg-card p-6 shadow-sm';
56
+ const linkExtra = 'group transition-all duration-200 hover:shadow-md hover:border-primary/40 block';
57
+ const Heading = `h${headingLevel}` as 'h2' | 'h3';
58
+ ---
59
+
60
+ {href ? (
61
+ <a href={href} class:list={[cardBase, linkExtra, className]}>
62
+ <article>
63
+ <div class="flex items-start gap-4">
64
+ <img
65
+ src={photoSrc}
66
+ alt={portraitAlt}
67
+ width="96"
68
+ height="96"
69
+ class="h-24 w-24 rounded-xl object-cover bg-muted shrink-0"
70
+ loading="lazy"
71
+ decoding="async"
72
+ />
73
+ <div class="min-w-0">
74
+ <p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary">{roleLabel}</p>
75
+ <Heading class="mt-2 text-xl font-display font-semibold text-foreground">{name}</Heading>
76
+ <p class:list={["mt-3 text-sm text-muted-foreground leading-relaxed", compact && "line-clamp-2"]}>{bio}</p>
77
+ </div>
78
+ </div>
79
+
80
+ <div class="mt-5 flex flex-wrap gap-2">
81
+ {expertise.map((topic) => (
82
+ <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
83
+ {topic}
84
+ </Badge>
85
+ ))}
86
+ </div>
87
+
88
+ <div class="mt-5 flex items-center justify-between gap-4">
89
+ {countLabel !== undefined && (
90
+ <p class="text-sm text-muted-foreground">
91
+ {countLabel}
92
+ </p>
93
+ )}
94
+ <ArrowLabel class="text-sm font-medium text-primary ml-auto">
95
+ {viewLabel}
96
+ </ArrowLabel>
97
+ </div>
98
+ </article>
99
+ </a>
100
+ ) : polaroid ? (
101
+ <article class:list={[cardBase, 'sm:p-8', className]}>
102
+ <div class="flex flex-col items-center gap-6 text-center lg:flex-row lg:items-start lg:gap-10 lg:text-left">
103
+ <div class="shrink-0 rotate-[-2deg] rounded-sm bg-white p-3 pb-8 shadow-xl ring-1 ring-black/5">
104
+ <img
105
+ src={photoSrc}
106
+ alt={portraitAlt}
107
+ width="256"
108
+ height="256"
109
+ class="h-48 w-48 sm:h-64 sm:w-64 rounded-sm object-cover bg-muted"
110
+ loading="lazy"
111
+ decoding="async"
112
+ />
113
+ {caption && (
114
+ <p class="mt-3 font-display italic text-sm text-neutral-600">{caption}</p>
115
+ )}
116
+ </div>
117
+
118
+ <div class="flex w-full flex-col items-center lg:items-start">
119
+ <p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary">{roleLabel}</p>
120
+ <Heading class="mt-1 text-xl font-display font-semibold text-foreground">{name}</Heading>
121
+ <p class="mt-4 max-w-xl text-sm text-muted-foreground leading-relaxed text-pretty">{bio}</p>
122
+ {credentials && <p class="mt-2 max-w-xl text-sm text-foreground leading-relaxed text-pretty">{credentials}</p>}
123
+
124
+ <div class="mt-5 flex flex-wrap justify-center gap-2 lg:justify-start">
125
+ {expertise.map((topic) => (
126
+ <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
127
+ {topic}
128
+ </Badge>
129
+ ))}
130
+ </div>
131
+
132
+ <div class="mt-5">
133
+ <LinkArrow href={profileHref} size="sm" color="primary">
134
+ {viewLabel}
135
+ </LinkArrow>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </article>
140
+ ) : (
141
+ <article class:list={[cardBase, className]}>
142
+ <div class="flex items-start gap-4">
143
+ <img
144
+ src={photoSrc}
145
+ alt={portraitAlt}
146
+ width="96"
147
+ height="96"
148
+ class="h-24 w-24 rounded-xl object-cover bg-muted shrink-0"
149
+ loading="lazy"
150
+ decoding="async"
151
+ />
152
+ <div class="min-w-0">
153
+ <p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary">{roleLabel}</p>
154
+ <Heading class="mt-2 text-xl font-display font-semibold text-foreground">{name}</Heading>
155
+ <p class="mt-3 text-sm text-muted-foreground leading-relaxed">{bio}</p>
156
+ {credentials && <p class="mt-3 text-sm text-foreground leading-relaxed">{credentials}</p>}
157
+ </div>
158
+ </div>
159
+
160
+ <div class="mt-5 flex flex-wrap gap-2">
161
+ {expertise.map((topic) => (
162
+ <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
163
+ {topic}
164
+ </Badge>
165
+ ))}
166
+ </div>
167
+
168
+ <div class="mt-5">
169
+ <LinkArrow href={profileHref} size="sm" color="primary">
170
+ {viewLabel}
171
+ </LinkArrow>
172
+ </div>
173
+ </article>
174
+ )}
@@ -0,0 +1,52 @@
1
+ ---
2
+ /** AuthorStrip — a centred row of author portraits linking to their profiles, with a link to the team page. */
3
+ import { Image } from 'astro:assets';
4
+ import type { ImageMetadata } from 'astro';
5
+ import type { AuthorSummary } from './types';
6
+
7
+ interface StripAuthor extends AuthorSummary {
8
+ displayName: string;
9
+ specialty: string;
10
+ image: ImageMetadata | string;
11
+ }
12
+
13
+ interface Props {
14
+ authors: readonly StripAuthor[];
15
+ ariaLabel: string;
16
+ heading: string;
17
+ teamHref: string;
18
+ teamLabel: string;
19
+ /** Portrait ring colour, e.g. "ring-background". */
20
+ ringClass?: string;
21
+ class?: string;
22
+ }
23
+
24
+ const { authors, ariaLabel, heading, teamHref, teamLabel, ringClass = 'ring-background', class: className = '' } = Astro.props;
25
+ const imgClass = `w-16 h-16 rounded-full object-cover ring-2 ${ringClass} group-hover:ring-primary/60 transition-shadow duration-200`;
26
+ ---
27
+ <section id="authors" aria-label={ariaLabel} class:list={["bg-muted/50 py-10", className]}>
28
+ <div class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8">
29
+ <p class="text-center text-sm font-medium text-muted-foreground mb-6">{heading}</p>
30
+ <div class="flex flex-wrap justify-center gap-6 sm:gap-8">
31
+ {authors.map((author) => (
32
+ <a
33
+ href={author.href}
34
+ class="group flex flex-col items-center gap-2 transition-opacity hover:opacity-80"
35
+ >
36
+ {typeof author.image === 'string' ? (
37
+ <img src={author.image} alt={author.name} width="64" height="64" class={imgClass} loading="lazy" decoding="async" />
38
+ ) : (
39
+ <Image src={author.image} alt={author.name} width={64} height={64} class={imgClass} loading="lazy" />
40
+ )}
41
+ <span class="text-sm font-medium text-foreground">{author.displayName}</span>
42
+ <span class="text-xs text-muted-foreground">{author.specialty}</span>
43
+ </a>
44
+ ))}
45
+ </div>
46
+ <div class="mt-6 text-center">
47
+ <a href={teamHref} class="text-sm font-medium text-primary hover:underline">
48
+ {teamLabel}
49
+ </a>
50
+ </div>
51
+ </div>
52
+ </section>
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * InsiderTips — the "reporter notebook" block: a header with an optional author byline, then one card per tip with a
4
+ * kind badge. Kinds are site-defined keys mapped to a label and a tone; tip bodies arrive as site-rendered HTML.
5
+ */
6
+ import Badge from '../primitives/Badge.astro';
7
+ import type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
8
+
9
+ interface Props {
10
+ items: InsiderTip[];
11
+ kinds: InsiderTipKinds;
12
+ title: string;
13
+ eyebrow: string;
14
+ /** Label above the author's name. */
15
+ filedByLabel: string;
16
+ author?: InsiderTipAuthor;
17
+ lede?: string;
18
+ /** Typography colour preset for the tip bodies, e.g. "prose-stone". */
19
+ proseClass?: string;
20
+ class?: string;
21
+ }
22
+
23
+ const { items, kinds, title, eyebrow, filedByLabel, author, lede, proseClass = 'prose-stone', class: className = '' } = Astro.props;
24
+
25
+ const toneClasses: Record<InsiderTipTone, { card: string; badge: string }> = {
26
+ accent: { card: 'border-accent/40 bg-accent/5', badge: 'bg-accent/20 text-accent-strong font-bold' },
27
+ primary: { card: 'border-primary/40 bg-primary/5', badge: 'bg-primary/15 text-primary' },
28
+ secondary: { card: 'border-secondary/40 bg-secondary/5', badge: 'bg-secondary/15 text-secondary' },
29
+ muted: { card: 'border-border bg-muted/35', badge: 'bg-muted text-foreground' },
30
+ };
31
+ const kindOf = (kind: string) => {
32
+ const k = kinds[kind];
33
+ if (!k) throw new Error(`InsiderTips: no label/tone for kind "${kind}"`);
34
+ return { label: k.label, ...toneClasses[k.tone] };
35
+ };
36
+ ---
37
+
38
+ {items.length > 0 && (
39
+ <section class:list={['rounded-2xl border border-primary/20 bg-muted/40 p-6 shadow-sm lg:p-8', className]}>
40
+ <div class="border-b border-primary/15 pb-5">
41
+ <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
42
+ <div class="max-w-deck">
43
+ <p class="text-xs font-semibold uppercase tracking-[0.22em] text-primary">{eyebrow}</p>
44
+ <h2 class="mt-2 text-xl font-semibold font-display">{title}</h2>
45
+ {lede && <p class="mt-2 text-sm leading-relaxed text-muted-foreground">{lede}</p>}
46
+ </div>
47
+
48
+ {author && (
49
+ <aside class="flex items-center gap-2 md:gap-3 lg:max-w-sm lg:justify-end">
50
+ {author.photoSrc ? (
51
+ <img
52
+ src={author.photoSrc}
53
+ alt={author.portraitAlt}
54
+ width="44"
55
+ height="44"
56
+ class="h-11 w-11 rounded-full object-cover bg-muted shrink-0"
57
+ loading="lazy"
58
+ decoding="async"
59
+ />
60
+ ) : (
61
+ <div class="h-11 w-11 rounded-full bg-primary/15 shrink-0" aria-hidden="true" />
62
+ )}
63
+ <div class="min-w-0">
64
+ <p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{filedByLabel}</p>
65
+ {author.href ? (
66
+ <a href={author.href} class="font-semibold text-foreground hover:text-primary transition-colors">{author.name}</a>
67
+ ) : (
68
+ <p class="font-semibold text-foreground">{author.name}</p>
69
+ )}
70
+ {author.expertise && (
71
+ <p class="mt-0.5 text-xs uppercase tracking-wide text-muted-foreground">{author.expertise}</p>
72
+ )}
73
+ {author.credentials && (
74
+ <p class="mt-1 text-sm leading-relaxed text-muted-foreground line-clamp-2">{author.credentials}</p>
75
+ )}
76
+ </div>
77
+ </aside>
78
+ )}
79
+ </div>
80
+ </div>
81
+
82
+ <div class:list={['mt-6 grid gap-4', items.length > 1 && 'md:grid-cols-2']}>
83
+ {items.map((item) => {
84
+ const k = kindOf(item.kind);
85
+ return (
86
+ // min-w-0 + break-words: a grid item defaults to min-width:auto, so one unbreakable token (a bare URL) would
87
+ // size the whole track and scroll the page sideways on a phone.
88
+ <article class:list={['min-w-0 break-words rounded-2xl border px-5 py-4', k.card]}>
89
+ <Badge tone="custom" toneClass={k.badge} size="md" lines={0} class="uppercase tracking-wide !font-semibold">
90
+ {k.label}
91
+ </Badge>
92
+ <h3 class="mt-3 text-lg font-semibold font-display text-foreground">{item.title}</h3>
93
+ <div
94
+ class={`prose ${proseClass} prose-sm mt-3 max-w-none text-foreground/80 prose-p:my-0 prose-strong:text-foreground`}
95
+ set:html={item.bodyHtml}
96
+ />
97
+ </article>
98
+ );
99
+ })}
100
+ </div>
101
+ </section>
102
+ )}
@@ -8,3 +8,24 @@ export { default as NotFoundPage } from './NotFoundPage.astro';
8
8
  export { default as ArticlesListingPage } from './ArticlesListingPage.astro';
9
9
  export { default as ArticleFeatureCard } from './ArticleFeatureCard.astro';
10
10
  export type { EventInfoRow, EventInfoIcon } from './event';
11
+ export { default as ArticleKeyFacts } from './ArticleKeyFacts.astro';
12
+ export { default as AuthorCard } from './AuthorCard.astro';
13
+ export { default as ArticleCard } from './ArticleCard.astro';
14
+ export { default as ArticleGrid } from './ArticleGrid.astro';
15
+ export { default as ArticleCategoryNav } from './ArticleCategoryNav.astro';
16
+ export type { AuthorSummary, ArticleSummary, KeyFact, SourceItem, ArticleCategoryLink } from './types';
17
+ export { default as InsiderTips } from './InsiderTips.astro';
18
+ export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
19
+ export { default as AuthorStrip } from './AuthorStrip.astro';
20
+ export { default as AnswerBox } from './interludes/AnswerBox.astro';
21
+ export { default as ComparisonTable } from './interludes/ComparisonTable.astro';
22
+ export { default as EvidenceContext } from './interludes/EvidenceContext.astro';
23
+ export { default as FieldNote } from './interludes/FieldNote.astro';
24
+ export { default as Quote } from './interludes/Quote.astro';
25
+ export { default as StatFact } from './interludes/StatFact.astro';
26
+ export { default as Timeline } from './interludes/Timeline.astro';
27
+ export { default as Tldr } from './interludes/Tldr.astro';
28
+ export { default as DidYouKnow } from './interludes/DidYouKnow.astro';
29
+ export { default as RelatedCard } from './interludes/RelatedCard.astro';
30
+ export { default as Interlude } from './interludes/Interlude.astro';
31
+ export type { InterludeData, InterludeLabels, RenderInline } from './interludes/types';
@@ -0,0 +1,23 @@
1
+ ---
2
+ /**
3
+ * AnswerBox — a single question answered in place, mid-article. The page merges these into its FAQPage JSON-LD,
4
+ * so the answer stays visible without interaction (no <details>).
5
+ */
6
+ import type { TrustedHtml } from '../../types';
7
+
8
+ interface Props {
9
+ question: TrustedHtml;
10
+ answer: TrustedHtml;
11
+ /** Eyebrow, e.g. "Quick answer". */
12
+ label: string;
13
+ }
14
+ const { question, answer, label } = Astro.props;
15
+ ---
16
+
17
+ <aside class="my-8 rounded-lg border border-secondary/30 bg-secondary/5 p-5 sm:p-6">
18
+ <p class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-secondary">
19
+ {label}
20
+ </p>
21
+ <p class="mb-2 text-base font-semibold leading-snug text-foreground" set:html={question} />
22
+ <p class="text-sm leading-relaxed text-muted-foreground sm:text-base" set:html={answer} />
23
+ </aside>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import type { TrustedHtml } from '../../types';
3
+
4
+ interface Props {
5
+ headers: TrustedHtml[];
6
+ rows: TrustedHtml[][];
7
+ }
8
+ const { headers, rows } = Astro.props;
9
+ ---
10
+
11
+ <figure class="my-8 overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
12
+ <table class="w-full text-sm border-collapse rounded-lg overflow-hidden border border-border bg-card">
13
+ <thead>
14
+ <tr class="bg-muted/60">
15
+ {headers.map((h) => (
16
+ <th scope="col" class="px-4 py-3 text-left font-semibold text-foreground border-b border-border" set:html={h} />
17
+ ))}
18
+ </tr>
19
+ </thead>
20
+ <tbody>
21
+ {rows.map((row, i) => (
22
+ <tr class={i % 2 === 1 ? 'bg-muted/20' : ''}>
23
+ {row.map((cell) => (
24
+ <td class="px-4 py-3 text-muted-foreground leading-relaxed border-b border-border last:border-b-0 align-top" set:html={cell} />
25
+ ))}
26
+ </tr>
27
+ ))}
28
+ </tbody>
29
+ </table>
30
+ </figure>
@@ -0,0 +1,42 @@
1
+ ---
2
+ /**
3
+ * DidYouKnow — a one-fact aside. `ruled`: top and bottom rules with a display-face body. `inline`: icon beside a
4
+ * smaller body. The label class is a prop so a site can pass its own eyebrow utility.
5
+ */
6
+ import Icon from '../../primitives/Icon.astro';
7
+ import type { TrustedHtml } from '../../types';
8
+
9
+ interface Props {
10
+ content: TrustedHtml;
11
+ /** E.g. "Did you know?". */
12
+ label: string;
13
+ variant?: 'ruled' | 'inline';
14
+ labelClass?: string;
15
+ }
16
+ const { content, label, variant = 'ruled', labelClass } = Astro.props;
17
+ ---
18
+
19
+ {variant === 'ruled' ? (
20
+ <aside class="my-12 sm:my-16 max-w-prose border-t-2 border-b-2 border-secondary py-6 sm:py-8">
21
+ <div class="flex items-center gap-2 mb-3">
22
+ <Icon name="lightbulb" class="w-4 h-4 text-secondary" aria-hidden="true" />
23
+ <p class={labelClass ?? 'text-xs font-semibold uppercase tracking-[0.2em] text-secondary'}>{label}</p>
24
+ </div>
25
+ <p
26
+ class="font-display text-lg sm:text-xl leading-snug text-foreground text-balance"
27
+ set:html={content}
28
+ />
29
+ </aside>
30
+ ) : (
31
+ <aside class="my-8 flex gap-3 items-start max-w-prose">
32
+ <Icon
33
+ name="lightbulb"
34
+ class="shrink-0 w-5 h-5 mt-1 text-accent"
35
+ aria-hidden="true"
36
+ />
37
+ <div>
38
+ <p class={labelClass ?? 'mb-1 text-xs font-semibold uppercase tracking-[0.18em] text-accent'}>{label}</p>
39
+ <p class="text-sm sm:text-base leading-relaxed text-foreground" set:html={content} />
40
+ </div>
41
+ </aside>
42
+ )}
@@ -0,0 +1,33 @@
1
+ ---
2
+ import type { TrustedHtml } from '../../types';
3
+
4
+ interface Props {
5
+ finding: TrustedHtml;
6
+ basis: TrustedHtml;
7
+ boundary: TrustedHtml;
8
+ /** Visible eyebrow and the aside's aria-label, e.g. "How to read this". */
9
+ title: string;
10
+ findingLabel: string;
11
+ basisLabel: string;
12
+ boundaryLabel: string;
13
+ }
14
+ const { finding, basis, boundary, title, findingLabel, basisLabel, boundaryLabel } = Astro.props;
15
+ ---
16
+
17
+ <aside class="my-8 rounded-lg border border-primary/25 bg-primary/5 p-5 sm:p-6" aria-label={title}>
18
+ <p class="mb-4 text-xs font-semibold uppercase tracking-[0.18em] text-primary">{title}</p>
19
+ <dl class="space-y-4">
20
+ <div>
21
+ <dt class="mb-1 text-sm font-semibold text-foreground">{findingLabel}</dt>
22
+ <dd class="text-sm leading-relaxed text-foreground sm:text-base" set:html={finding} />
23
+ </div>
24
+ <div>
25
+ <dt class="mb-1 text-sm font-semibold text-foreground/80">{basisLabel}</dt>
26
+ <dd class="text-sm leading-relaxed text-muted-foreground sm:text-base" set:html={basis} />
27
+ </div>
28
+ <div>
29
+ <dt class="mb-1 text-sm font-semibold text-accent">{boundaryLabel}</dt>
30
+ <dd class="text-sm leading-relaxed text-muted-foreground sm:text-base" set:html={boundary} />
31
+ </div>
32
+ </dl>
33
+ </aside>
@@ -0,0 +1,37 @@
1
+ ---
2
+ /**
3
+ * FieldNote — a first-person aside from the article's byline author. Author name only: photo and credentials
4
+ * belong to the byline. Rule-and-indent rather than AnswerBox's panel, so a voice moment never reads as a Q&A.
5
+ */
6
+ import type { TrustedHtml } from '../../types';
7
+
8
+ interface Props {
9
+ content: TrustedHtml;
10
+ author?: string;
11
+ /** Links the signature to the author's profile (rel="author"). */
12
+ authorHref?: string;
13
+ /** Eyebrow, e.g. "From the field". */
14
+ label: string;
15
+ class?: string;
16
+ }
17
+ const { content, author, authorHref, label, class: className } = Astro.props;
18
+ ---
19
+
20
+ <aside class:list={['my-8 max-w-prose border-l-2 border-accent/60 pl-5 sm:pl-6', className]}>
21
+ <p class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-accent">
22
+ {label}
23
+ </p>
24
+ <p class="text-sm italic leading-relaxed text-foreground sm:text-base" set:html={content} />
25
+ {author && !authorHref && (
26
+ <p class="mt-3 text-xs not-italic text-muted-foreground">— {author}</p>
27
+ )}
28
+ {author && authorHref && (
29
+ <p class="mt-3 text-xs not-italic text-muted-foreground">
30
+ — <a
31
+ href={authorHref}
32
+ rel="author"
33
+ class="rounded text-muted-foreground underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
34
+ >{author}</a>
35
+ </p>
36
+ )}
37
+ </aside>
@@ -0,0 +1,91 @@
1
+ ---
2
+ /**
3
+ * Interlude — renders one authored interlude. Entity types (related-card, monument-feature, article-feature) render
4
+ * nothing here: their target and image are site data, so the site's adapter renders RelatedCard for them.
5
+ */
6
+ import Callout from '../../primitives/Callout.astro';
7
+ import FeatureList from '../../primitives/FeatureList.astro';
8
+ import { trustedHtml } from '../../types';
9
+ import AnswerBox from './AnswerBox.astro';
10
+ import ComparisonTable from './ComparisonTable.astro';
11
+ import DidYouKnow from './DidYouKnow.astro';
12
+ import EvidenceContext from './EvidenceContext.astro';
13
+ import FieldNote from './FieldNote.astro';
14
+ import Quote from './Quote.astro';
15
+ import StatFact from './StatFact.astro';
16
+ import Timeline from './Timeline.astro';
17
+ import Tldr from './Tldr.astro';
18
+ import type { InterludeData, InterludeLabels, RenderInline } from './types';
19
+
20
+ interface Props {
21
+ interlude: InterludeData;
22
+ renderInline: RenderInline;
23
+ labels: InterludeLabels;
24
+ /** Byline author, signs a `field-note`. */
25
+ author?: string;
26
+ didYouKnowVariant?: 'ruled' | 'inline';
27
+ didYouKnowLabelClass?: string;
28
+ }
29
+ const { interlude: il, renderInline: md, labels, author, didYouKnowVariant, didYouKnowLabelClass } = Astro.props;
30
+ ---
31
+
32
+ {il.type === 'quote' && (
33
+ <Quote
34
+ variant={il.variant}
35
+ content={md(il.content)}
36
+ attribution={il.attribution && { name: md(il.attribution.name), role: il.attribution.role ? md(il.attribution.role) : undefined }}
37
+ />
38
+ )}
39
+
40
+ {il.type === 'stat-fact' && <StatFact figure={trustedHtml(il.figure)} caption={md(il.caption)} />}
41
+
42
+ {il.type === 'evidence-context' && (
43
+ <EvidenceContext
44
+ finding={md(il.finding)}
45
+ basis={md(il.basis)}
46
+ boundary={md(il.boundary)}
47
+ title={labels.evidence.title}
48
+ findingLabel={labels.evidence.finding}
49
+ basisLabel={labels.evidence.basis}
50
+ boundaryLabel={labels.evidence.boundary}
51
+ />
52
+ )}
53
+
54
+ {il.type === 'tldr' && (
55
+ <Tldr
56
+ content={il.content === undefined ? trustedHtml('') : md(il.content)}
57
+ bullets={(il.bullets ?? []).map(md)}
58
+ title={il.title}
59
+ bulletsLabel={labels.tldr.bullets}
60
+ proseLabel={labels.tldr.prose}
61
+ />
62
+ )}
63
+
64
+ {il.type === 'did-you-know' && (
65
+ <DidYouKnow content={md(il.content)} label={labels.didYouKnow} variant={didYouKnowVariant} labelClass={didYouKnowLabelClass} />
66
+ )}
67
+
68
+ {il.type === 'timeline' && (
69
+ <Timeline
70
+ events={il.events.map((e) => ({ date: e.date, label: md(e.label), description: e.description ? md(e.description) : undefined }))}
71
+ label={labels.timeline}
72
+ />
73
+ )}
74
+
75
+ {il.type === 'comparison-table' && <ComparisonTable headers={il.headers.map(md)} rows={il.rows.map((row) => row.map(md))} />}
76
+
77
+ {il.type === 'answer-box' && <AnswerBox question={md(il.question)} answer={md(il.answer)} label={labels.answerBox} />}
78
+
79
+ {il.type === 'field-note' && <FieldNote content={md(il.content)} author={author} label={labels.fieldNote} />}
80
+
81
+ {il.type === 'caution' && (
82
+ <Callout type={il.variant} title={il.title} class="my-8">
83
+ <Fragment set:html={md(il.content)} />
84
+ </Callout>
85
+ )}
86
+
87
+ {il.type === 'checklist' && (
88
+ <div class="my-6">
89
+ <FeatureList title={il.title} items={il.items.map(md)} variant="checklist" headingLevel="h3" container compact />
90
+ </div>
91
+ )}
@@ -0,0 +1,41 @@
1
+ ---
2
+ import type { TrustedHtml } from '../../types';
3
+
4
+ interface Attribution {
5
+ name: TrustedHtml;
6
+ role?: TrustedHtml;
7
+ }
8
+ interface Props {
9
+ variant: 'pull' | 'external';
10
+ content: TrustedHtml;
11
+ attribution?: Attribution;
12
+ }
13
+ const { variant, content, attribution } = Astro.props;
14
+ ---
15
+
16
+ {variant === 'pull' && (
17
+ <figure class="my-10 pl-6 sm:pl-10 border-l border-primary/30">
18
+ <blockquote class="text-xl sm:text-2xl font-display italic leading-snug text-foreground/90 text-balance">
19
+ <span set:html={content} />
20
+ </blockquote>
21
+ </figure>
22
+ )}
23
+
24
+ {variant === 'external' && (
25
+ <figure class="relative my-10 pl-12 pr-6 py-7 sm:pl-14 sm:pr-8 sm:py-8 rounded-lg bg-muted border border-border">
26
+ <span
27
+ class="pointer-events-none absolute left-4 top-3 sm:left-5 select-none font-display text-5xl sm:text-6xl leading-none text-accent"
28
+ aria-hidden="true"
29
+ >&ldquo;</span>
30
+ <blockquote class="text-lg sm:text-xl italic leading-relaxed text-foreground/90 text-balance">&ldquo;<span set:html={content} />&rdquo;</blockquote>
31
+ {attribution && (
32
+ <figcaption class="mt-4 flex items-baseline gap-2 text-sm text-muted-foreground">
33
+ <span class="block h-px w-6 bg-primary/40" aria-hidden="true"></span>
34
+ <cite class="not-italic">
35
+ <span class="font-semibold text-foreground" set:html={attribution.name} />
36
+ {attribution.role && <span class="ml-1.5 text-muted-foreground/80">, <span set:html={attribution.role} /></span>}
37
+ </cite>
38
+ </figcaption>
39
+ )}
40
+ </figure>
41
+ )}