@sarimarcus/content-sites-ui 0.19.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.20.0 (2026-10-02)
6
+
7
+ - `./editorial` article interludes: `Interlude` dispatcher (site `renderInline`, `labels`, `didYouKnowVariant`; entity interludes left to the site), `AnswerBox`, `ComparisonTable`, `EvidenceContext`, `FieldNote` (`authorHref`, `class`), `Quote`, `StatFact`, `Timeline`, `Tldr`, `DidYouKnow` (`ruled` / `inline`, `labelClass`) and `RelatedCard` (`inline` / `featured`, `eyebrowClass`). Every visible and aria string is a required prop; HTML props are `TrustedHtml`. Types `InterludeData`, `InterludeLabels`, `RenderInline` (SLA-2740).
8
+
5
9
  ## 0.19.0 (2026-10-02)
6
10
 
7
11
  - `./editorial` editorial presentation: `ArticleKeyFacts`, `AuthorCard` (link, polaroid and plain shapes), `AuthorStrip`, `ArticleCard` and `ArticleGrid`, `ArticleCategoryNav`, `InsiderTips` (site-defined tip kinds). Every visible and aria string is a required prop. New view-model types `ArticleSummary`, `AuthorSummary`, `KeyFact`, `SourceItem`, `ArticleCategoryLink`, `InsiderTip`, `InsiderTipKinds`, `InsiderTipAuthor` (SLA-2740).
package/README.md CHANGED
@@ -128,6 +128,28 @@ let a site keep its own roles.
128
128
  These render inside the site's article styles (`.article-body` link rules, typography plugin presets); outside them
129
129
  they look different.
130
130
 
131
+ ### Article interludes
132
+
133
+ ```astro
134
+ ---
135
+ import { Interlude, RelatedCard } from '@sarimarcus/content-sites-ui/editorial';
136
+ import { trustedHtml } from '@sarimarcus/content-sites-ui/types';
137
+ const renderInline = (s: string) => trustedHtml(parseMarkdownInline(s, basePath, sourcesLength));
138
+ ---
139
+ {interlude.type === 'related-card' && <RelatedCard href={href} eyebrow={`Read next · ${label}`} title={name} />}
140
+ <Interlude interlude={interlude} renderInline={renderInline} labels={labels} author={author} />
141
+ ```
142
+
143
+ `Interlude` renders the eleven content interludes (quote, stat-fact, evidence-context, tldr, answer-box, field-note,
144
+ caution, checklist, did-you-know, timeline, comparison-table) and nothing for the entity ones (`related-card`,
145
+ `monument-feature`, `article-feature`): their target, route and image are site data, so the site's adapter renders
146
+ `RelatedCard` (`inline` row or `featured` card) for them. Every string field passes through the site's `renderInline`
147
+ except the stat figure. `labels` carries all the copy; `didYouKnowVariant` picks `ruled` or `inline`.
148
+
149
+ The pieces are exported too, for pages that place an interlude by hand: `AnswerBox`, `ComparisonTable`,
150
+ `EvidenceContext`, `FieldNote` (`authorHref` links the signature), `Quote`, `StatFact`, `Timeline`, `Tldr`,
151
+ `DidYouKnow`. The content schema is core's `createInterludeSchema` (`@sarimarcus/content-sites-core/interludes`).
152
+
131
153
  ## Document shell
132
154
 
133
155
  ```astro
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,6 +49,6 @@
49
49
  "@lucide/astro": "^1.23.0",
50
50
  "astro": "^7.0.6",
51
51
  "tailwindcss": "^4.1.18",
52
- "@sarimarcus/content-sites-core": "0.19.0"
52
+ "@sarimarcus/content-sites-core": "0.20.0"
53
53
  }
54
54
  }
@@ -17,3 +17,15 @@ export type { AuthorSummary, ArticleSummary, KeyFact, SourceItem, ArticleCategor
17
17
  export { default as InsiderTips } from './InsiderTips.astro';
18
18
  export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
19
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
+ )}
@@ -0,0 +1,87 @@
1
+ ---
2
+ /**
3
+ * RelatedCard — an in-article link to another page. `inline`: a ruled "read next" row. `featured`: the mid-article
4
+ * feature card, image left. The site resolves href, eyebrow and image; an unresolved target should render nothing.
5
+ */
6
+ import type { ImageMetadata } from 'astro';
7
+ import { Picture } from 'astro:assets';
8
+ import Icon from '../../primitives/Icon.astro';
9
+
10
+ interface Props {
11
+ href: string;
12
+ /** Full eyebrow text, e.g. "Read next · Monument". */
13
+ eyebrow: string;
14
+ title: string;
15
+ description?: string;
16
+ variant?: 'inline' | 'featured';
17
+ image?: ImageMetadata;
18
+ imageAlt?: string;
19
+ eyebrowClass?: string;
20
+ }
21
+ const { href, eyebrow, title, description, variant = 'inline', image, imageAlt, eyebrowClass } = Astro.props;
22
+ ---
23
+
24
+ {variant === 'inline' ? (
25
+ <a
26
+ href={href}
27
+ class="group not-prose my-8 flex items-center justify-between gap-6 py-4 border-y border-border hover:border-primary/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background transition-colors"
28
+ >
29
+ <div class="min-w-0 flex-1">
30
+ <p class={eyebrowClass ?? 'text-xs font-semibold uppercase tracking-[0.16em] text-primary mb-1'}>
31
+ {eyebrow}
32
+ </p>
33
+ <h4 class="text-base sm:text-lg font-semibold font-display text-foreground leading-snug group-hover:text-primary transition-colors">
34
+ {title}
35
+ </h4>
36
+ {description && (
37
+ <p class="mt-1 text-sm text-muted-foreground leading-relaxed line-clamp-2">{description}</p>
38
+ )}
39
+ </div>
40
+ <Icon
41
+ name="arrow-right"
42
+ class="shrink-0 w-5 h-5 text-primary transition-transform motion-safe:group-hover:translate-x-1"
43
+ aria-hidden="true"
44
+ />
45
+ </a>
46
+ ) : (
47
+ <a
48
+ href={href}
49
+ class="group not-prose my-8 flex flex-col sm:flex-row overflow-hidden rounded-xl border border-border bg-card hover:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background transition-colors"
50
+ >
51
+ <div class="sm:w-2/5 shrink-0 overflow-hidden bg-muted aspect-video sm:aspect-auto">
52
+ {image ? (
53
+ <Picture
54
+ src={image}
55
+ alt={imageAlt ?? title}
56
+ widths={[320, 480]}
57
+ sizes="(min-width: 640px) 40vw, 100vw"
58
+ formats={['avif', 'webp']}
59
+ fallbackFormat="webp"
60
+ class="w-full h-full object-cover"
61
+ loading="lazy"
62
+ />
63
+ ) : (
64
+ <div class="w-full h-full min-h-[160px] sm:min-h-full bg-muted" />
65
+ )}
66
+ </div>
67
+
68
+ <div class="flex flex-col justify-center gap-2 p-5 sm:p-6 min-w-0 flex-1">
69
+ <p class={eyebrowClass ?? 'text-xs font-medium uppercase tracking-[0.16em] text-primary'}>
70
+ {eyebrow}
71
+ </p>
72
+ <h4 class="text-lg font-semibold font-display text-foreground leading-snug group-hover:text-primary transition-colors">
73
+ {title}
74
+ </h4>
75
+ {description && (
76
+ <p class="text-sm text-muted-foreground leading-relaxed line-clamp-2">{description}</p>
77
+ )}
78
+ <div class="flex justify-end mt-1">
79
+ <Icon
80
+ name="arrow-right"
81
+ class="w-4 h-4 text-primary transition-transform motion-safe:group-hover:translate-x-1"
82
+ aria-hidden="true"
83
+ />
84
+ </div>
85
+ </div>
86
+ </a>
87
+ )}
@@ -0,0 +1,18 @@
1
+ ---
2
+ import type { TrustedHtml } from '../../types';
3
+
4
+ interface Props {
5
+ figure: TrustedHtml;
6
+ caption: TrustedHtml;
7
+ }
8
+ const { figure, caption } = Astro.props;
9
+ ---
10
+
11
+ <figure class="my-16 sm:my-20 text-center">
12
+ <span class="block mx-auto h-px w-12 bg-primary/40 mb-6" aria-hidden="true"></span>
13
+ <p
14
+ class="font-display text-5xl sm:text-6xl text-primary leading-none tracking-[-0.03em] tabular-nums"
15
+ set:html={figure}
16
+ />
17
+ <figcaption class="mt-5 text-sm leading-relaxed text-muted-foreground max-w-md mx-auto" set:html={caption} />
18
+ </figure>
@@ -0,0 +1,34 @@
1
+ ---
2
+ import type { TrustedHtml } from '../../types';
3
+
4
+ interface TimelineEvent {
5
+ date: string;
6
+ label: TrustedHtml;
7
+ description?: TrustedHtml;
8
+ }
9
+ interface Props {
10
+ events: TimelineEvent[];
11
+ /** Figcaption, e.g. "Timeline". */
12
+ label: string;
13
+ }
14
+ const { events, label } = Astro.props;
15
+ ---
16
+
17
+ <figure class="my-8 p-6 sm:p-7 rounded-lg bg-card border border-border">
18
+ <figcaption class="text-xs font-semibold uppercase tracking-[0.18em] text-secondary mb-5">{label}</figcaption>
19
+ <ol class="space-y-4">
20
+ {events.map((event) => (
21
+ <li class="grid grid-cols-[5rem_1fr] sm:grid-cols-[7rem_1fr] gap-x-4 gap-y-1">
22
+ <span class="text-sm font-semibold font-display text-secondary tabular-nums leading-snug pt-0.5">
23
+ {event.date}
24
+ </span>
25
+ <div>
26
+ <p class="text-sm sm:text-base font-semibold text-foreground leading-snug" set:html={event.label} />
27
+ {event.description && (
28
+ <p class="mt-1 text-sm text-muted-foreground leading-relaxed" set:html={event.description} />
29
+ )}
30
+ </div>
31
+ </li>
32
+ ))}
33
+ </ol>
34
+ </figure>
@@ -0,0 +1,40 @@
1
+ ---
2
+ /**
3
+ * Tldr — end-of-section summary: a prose paragraph (`content`) or a "key takeaways" list (`bullets`).
4
+ * An authored `title` wins over the shape's label.
5
+ */
6
+ import type { TrustedHtml } from '../../types';
7
+
8
+ interface Props {
9
+ content?: TrustedHtml;
10
+ bullets?: TrustedHtml[];
11
+ title?: string;
12
+ /** Heading for the bullets shape, e.g. "Key takeaways". */
13
+ bulletsLabel: string;
14
+ /** Heading for the prose shape, e.g. "In short". */
15
+ proseLabel: string;
16
+ }
17
+ const { content, bullets = [], title, bulletsLabel, proseLabel } = Astro.props;
18
+ const hasBullets = bullets.length > 0;
19
+ const heading = title ?? (hasBullets ? bulletsLabel : proseLabel);
20
+ ---
21
+
22
+ <aside class="my-8 p-5 sm:p-6 rounded-lg bg-muted border border-border">
23
+ <p class="inline-block text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-3 pb-1 border-b border-accent">
24
+ {heading}
25
+ </p>
26
+ {hasBullets ? (
27
+ /* list-none + an absolute dot: the aside sits inside .article-body, whose `ul` rule would add its own
28
+ padding and marker. */
29
+ <ul class="m-0 list-none space-y-2.5 p-0">
30
+ {bullets.map((b) => (
31
+ <li class="relative pl-6 text-sm sm:text-base leading-relaxed text-foreground">
32
+ <span class="absolute left-0 top-[0.62em] block h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true"></span>
33
+ <span set:html={b} />
34
+ </li>
35
+ ))}
36
+ </ul>
37
+ ) : (
38
+ <p class="text-sm sm:text-base leading-relaxed text-foreground" set:html={content} />
39
+ )}
40
+ </aside>
@@ -0,0 +1,36 @@
1
+ import type { InterludeType } from '@sarimarcus/content-sites-core/interludes';
2
+ import type { TrustedHtml } from '../../types';
3
+
4
+ /** One authored interlude as the content layer returns it. String fields are markdown until `renderInline` runs. */
5
+ export type InterludeData =
6
+ | { type: 'quote'; variant: 'pull' | 'external'; content: string; attribution?: { name: string; role?: string } }
7
+ | { type: 'stat-fact'; figure: string; caption: string }
8
+ | { type: 'evidence-context'; finding: string; basis: string; boundary: string }
9
+ | { type: 'tldr'; content?: string; bullets?: string[]; title?: string }
10
+ | { type: 'answer-box'; question: string; answer: string }
11
+ | { type: 'field-note'; content: string }
12
+ | { type: 'caution'; variant: 'tip' | 'warning' | 'info'; title?: string; content: string }
13
+ | { type: 'checklist'; title: string; items: string[] }
14
+ | { type: 'did-you-know'; content: string }
15
+ | { type: 'timeline'; events: Array<{ date: string; label: string; description?: string }> }
16
+ | { type: 'comparison-table'; headers: string[]; rows: string[][] }
17
+ /** Entity interludes: the site resolves the target and renders RelatedCard itself. */
18
+ | { type: 'related-card' | 'monument-feature' | 'article-feature' };
19
+
20
+ // Fails to compile when core's union gains or loses a tag this type does not mirror.
21
+ type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
22
+ const mirrorsCore: Same<InterludeData['type'], InterludeType> = true;
23
+ void mirrorsCore;
24
+
25
+ /** Every string the interludes print. */
26
+ export interface InterludeLabels {
27
+ answerBox: string;
28
+ evidence: { title: string; finding: string; basis: string; boundary: string };
29
+ fieldNote: string;
30
+ timeline: string;
31
+ tldr: { bullets: string; prose: string };
32
+ didYouKnow: string;
33
+ }
34
+
35
+ /** The site's inline markdown renderer: links resolved against its base path, footnote markers bounded. */
36
+ export type RenderInline = (markdown: string) => TrustedHtml;