@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
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
+
9
+ ## 0.19.0 (2026-10-02)
10
+
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).
12
+ - `./composed`: `RelatedContent` and `RelatedContentCard` (grid or carousel of `RelatedItem` cards, `tone` primary/accent/secondary; types `RelatedItem`, `RelatedTone`). In `./composed` because they render CardCarousel, whose `<style>` would otherwise reach every `./editorial` importer (SLA-2740).
13
+ - `ArticleFeatureCard`: `label` optional; new `readingLabel`, `loading` (lazy drops the high fetch priority), `headingLevel`, `pagefindIgnore` and `class` (replaces the default `mb-12`) (SLA-2740).
14
+
5
15
  ## 0.18.0 (2026-10-02)
6
16
 
7
17
  - New page templates in `./editorial`: `StaticProsePage` (legal and site-information pages: heading, optional eyebrow, prose slot, `after` slot, closing CTA box; not Pagefind-indexed), `NotFoundPage` (the 404 body, every string a prop; its decoration ships as `./not-found.css` so a barrel import never carries it to other pages), `ArticlesListingPage` (article index and topic hub shell: breadcrumb band, header, `<main>`, Pagefind body optional) and `ArticleFeatureCard` (SLA-2744).
package/README.md CHANGED
@@ -100,6 +100,56 @@ prose stay in the route so the site's link and outbound-domain scanners still se
100
100
  A component exported from a barrel must not carry a `<style>` block: Astro ships it to every page that imports the
101
101
  barrel. Put such rules in a stylesheet entry the one route imports.
102
102
 
103
+ ## Editorial presentation
104
+
105
+ ```ts
106
+ import { ArticleKeyFacts, AuthorCard, AuthorStrip, ArticleCard, ArticleGrid, ArticleCategoryNav, InsiderTips } from '@sarimarcus/content-sites-ui/editorial';
107
+ import { RelatedContent, RelatedContentCard } from '@sarimarcus/content-sites-ui/composed';
108
+ import type { ArticleSummary, AuthorSummary, KeyFact, SourceItem } from '@sarimarcus/content-sites-ui/editorial';
109
+ import type { RelatedItem } from '@sarimarcus/content-sites-ui/composed';
110
+ ```
111
+
112
+ Every visible and aria string is a required prop with no default, so the copy stays in the site. The site keeps a thin
113
+ adapter of the same name that reads its collections, globs, routes, markdown renderer and author profiles, then passes
114
+ view models: dates already formatted (the package never picks a time zone), hrefs with the base path applied, HTML that
115
+ crosses the boundary only through `trustedHtml()` (key-fact details and tip bodies keep their `#fn-N` anchors because
116
+ the site parses them with its source count). Palette props (`figureClass`, `surfaceClass`, `ringClass`, `proseClass`)
117
+ let a site keep its own roles.
118
+
119
+ - `ArticleKeyFacts`: figure-first `<dl>`, rendered only with three or more facts.
120
+ - `AuthorCard`: whole-card link, polaroid, or plain card with a profile link. `AuthorStrip`: portraits row.
121
+ - `ArticleCard` / `ArticleGrid`: listing cards; `pagefindIgnore` for a listing that is itself indexed.
122
+ `ArticleFeatureCard` is the wide lead card.
123
+ - `ArticleCategoryNav`: "All" plus one chip per topic; the site supplies the links and counts.
124
+ - `RelatedContent` / `RelatedContentCard` (in `./composed`, beside the CardCarousel they render): grid or carousel of `RelatedItem` cards, `tone` primary/accent/secondary.
125
+ Resolving which items to show (collections, scoring, backfill) stays a site adapter.
126
+ - `InsiderTips`: tip kinds are site keys mapped to a label and a tone.
127
+
128
+ These render inside the site's article styles (`.article-body` link rules, typography plugin presets); outside them
129
+ they look different.
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
+
103
153
  ## Document shell
104
154
 
105
155
  ```astro
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.18.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.18.0"
52
+ "@sarimarcus/content-sites-core": "0.20.0"
53
53
  }
54
54
  }
@@ -0,0 +1,135 @@
1
+ ---
2
+ /**
3
+ * RelatedContent — related link cards as a grid (default) or a swipable carousel. Under `carousel`, `columns` sets
4
+ * the card width (how many fit a desktop viewport) rather than grid columns.
5
+ */
6
+ import ArrowLabel from '../primitives/ArrowLabel.astro';
7
+ import SectionDivider from '../primitives/SectionDivider.astro';
8
+ import CardCarousel from './CardCarousel.astro';
9
+ import RelatedContentCard from './RelatedContentCard.astro';
10
+ import type { RelatedItem } from './related';
11
+
12
+ interface Props {
13
+ /** Omit only when the caller renders its own heading (see `plain`). */
14
+ title?: string;
15
+ items: RelatedItem[];
16
+ /**
17
+ * `bordered`: hairline divider above, no surface. `card`: tinted, bordered panel. `plain`: no surface and no
18
+ * divider, for a caller that owns its heading and separation.
19
+ */
20
+ variant?: 'bordered' | 'card' | 'plain';
21
+ layout?: 'grid' | 'carousel';
22
+ columns?: 2 | 3;
23
+ headingLevel?: 2 | 3;
24
+ viewAllHref?: string;
25
+ /** Required with `viewAllHref`. */
26
+ viewAllLabel?: string;
27
+ trackingBlock?: string;
28
+ /** Carousel track label; defaults to the title. */
29
+ carouselAriaLabel?: string;
30
+ /** Image `sizes` override; the defaults assume a ~700px prose column. */
31
+ pictureSizes?: string;
32
+ class?: string;
33
+ }
34
+
35
+ const {
36
+ title,
37
+ items,
38
+ variant = 'bordered',
39
+ layout = 'grid',
40
+ columns = 2,
41
+ headingLevel = 2,
42
+ viewAllHref,
43
+ viewAllLabel,
44
+ trackingBlock,
45
+ carouselAriaLabel,
46
+ pictureSizes: pictureSizesProp,
47
+ class: className = '',
48
+ } = Astro.props;
49
+
50
+ if (viewAllHref && !viewAllLabel) throw new Error('RelatedContent: viewAllHref needs a viewAllLabel');
51
+
52
+ const HeadingTag = `h${headingLevel}` as 'h2' | 'h3';
53
+
54
+ // A titled block is tangential content (aside); an untitled one is the caller's own payload, and an unnamed aside
55
+ // would add a second, unlabelled landmark inside it.
56
+ const Wrapper = title ? 'aside' : 'div';
57
+
58
+ const wrapperClass = variant === 'bordered'
59
+ ? 'mt-8'
60
+ : variant === 'plain'
61
+ ? ''
62
+ : 'p-6 bg-muted/50 rounded-xl border border-border';
63
+
64
+ const gridClass = columns === 3
65
+ ? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
66
+ : 'grid gap-4 sm:grid-cols-2';
67
+
68
+ // At `lg` a card fits `columns` per view, so the track stops overflowing and the controls hide. Percentages, not
69
+ // fixed widths: the block usually sits inside a ~700px prose column. Below `lg` cards are wider than a column share
70
+ // so the next one peeks in.
71
+ const cardWidthClass = columns === 3
72
+ ? 'w-[80vw] sm:w-64 lg:w-[calc((100%-2rem)/3)]'
73
+ : 'w-[85vw] sm:w-80 lg:w-[calc((100%-1rem)/2)]';
74
+
75
+ const pictureSizes = pictureSizesProp ?? (layout === 'carousel'
76
+ ? (columns === 3
77
+ ? '(min-width: 1024px) 240px, (min-width: 640px) 256px, 80vw'
78
+ : '(min-width: 640px) 320px, 85vw')
79
+ : (columns === 3
80
+ ? '(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw'
81
+ : '(min-width: 640px) 50vw, 100vw'));
82
+
83
+ // CardCarousel's contract: every direct child of the track is one card with `snap-start shrink-0` and a width.
84
+ const carouselShell = `shrink-0 snap-start ${cardWidthClass}`;
85
+ ---
86
+
87
+ {items.length > 0 && (
88
+ <Wrapper class:list={[wrapperClass, className]} aria-label={title || undefined}>
89
+ {variant === 'bordered' && <SectionDivider />}
90
+
91
+ {layout === 'carousel' ? (
92
+ <CardCarousel
93
+ heading={title}
94
+ headingTag={HeadingTag}
95
+ headingClass="text-2xl font-display font-semibold text-foreground"
96
+ viewAllHref={viewAllHref}
97
+ viewAllLabel={viewAllLabel}
98
+ ariaLabel={carouselAriaLabel ?? title}
99
+ >
100
+ {items.map((item) => (
101
+ <RelatedContentCard
102
+ item={item}
103
+ shellClass={carouselShell}
104
+ pictureSizes={pictureSizes}
105
+ trackingBlock={trackingBlock}
106
+ />
107
+ ))}
108
+ </CardCarousel>
109
+ ) : null}
110
+
111
+ {/* Gated on `title || viewAllHref`: this row is the only consumer of viewAllHref in grid layout. */}
112
+ {layout !== 'carousel' && (title || viewAllHref) && (
113
+ <div class:list={['mb-6', viewAllHref && 'flex flex-wrap items-baseline justify-between gap-x-6 gap-y-2 mb-8']}>
114
+ {title && <HeadingTag class="text-2xl font-display font-semibold text-foreground">{title}</HeadingTag>}
115
+ {viewAllHref && (
116
+ <a href={viewAllHref} class="group text-sm font-medium text-primary hover:text-primary-strong transition-colors">
117
+ <ArrowLabel>{viewAllLabel}</ArrowLabel>
118
+ </a>
119
+ )}
120
+ </div>
121
+ )}
122
+
123
+ {layout !== 'carousel' && (
124
+ <div class={gridClass}>
125
+ {items.map((item) => (
126
+ <RelatedContentCard
127
+ item={item}
128
+ pictureSizes={pictureSizes}
129
+ trackingBlock={trackingBlock}
130
+ />
131
+ ))}
132
+ </div>
133
+ )}
134
+ </Wrapper>
135
+ )}
@@ -0,0 +1,99 @@
1
+ ---
2
+ /**
3
+ * RelatedContentCard — one card inside RelatedContent. The grid and the carousel render this same card; only the
4
+ * shell classes and the responsive image hints differ. Three shapes, by precedence: image-led, icon-led, plain text.
5
+ */
6
+ import { Picture } from 'astro:assets';
7
+ import Icon from '../primitives/Icon.astro';
8
+ import type { RelatedItem, RelatedTone } from './related';
9
+
10
+ interface Props {
11
+ item: RelatedItem;
12
+ /** Layout-specific classes appended to the shared shell. */
13
+ shellClass?: string;
14
+ pictureSizes: string;
15
+ trackingBlock?: string;
16
+ }
17
+
18
+ const { item, shellClass = '', pictureSizes, trackingBlock } = Astro.props;
19
+
20
+ const toneMap: Record<RelatedTone, { border: string; text: string; iconBg: string; iconText: string }> = {
21
+ primary: {
22
+ border: 'hover:border-primary/40',
23
+ text: 'group-hover:text-primary',
24
+ iconBg: 'bg-primary/20 group-hover:bg-primary/30',
25
+ iconText: 'text-primary',
26
+ },
27
+ accent: {
28
+ border: 'hover:border-accent',
29
+ text: 'group-hover:text-accent-strong',
30
+ iconBg: 'bg-accent/20 group-hover:bg-accent/30',
31
+ iconText: 'text-accent-strong',
32
+ },
33
+ secondary: {
34
+ border: 'hover:border-secondary',
35
+ text: 'group-hover:text-secondary',
36
+ iconBg: 'bg-secondary/20 group-hover:bg-secondary/30',
37
+ iconText: 'text-secondary',
38
+ },
39
+ };
40
+
41
+ const c = toneMap[item.tone ?? 'primary'];
42
+ const hasIcon = !!item.icon;
43
+ const hasImage = !!item.image;
44
+ ---
45
+
46
+ <a
47
+ href={item.href}
48
+ aria-label={item.title}
49
+ {...(trackingBlock ? {
50
+ 'data-track-event': 'internal_link_click',
51
+ 'data-track-link-block': trackingBlock,
52
+ } : {})}
53
+ class:list={[
54
+ 'group rounded-2xl border border-border bg-card transition-colors hover:bg-muted/50 overflow-hidden',
55
+ c.border,
56
+ hasImage ? 'flex flex-col' : (hasIcon ? 'flex flex-col gap-3 p-5' : 'flex flex-col gap-2 p-5'),
57
+ shellClass,
58
+ ]}
59
+ >
60
+ {hasImage && (
61
+ <div class="relative aspect-video overflow-hidden">
62
+ <Picture
63
+ src={item.image!}
64
+ alt={item.imageAlt || item.title}
65
+ widths={[320, 480, 640]}
66
+ formats={['avif', 'webp']}
67
+ fallbackFormat="webp"
68
+ sizes={pictureSizes}
69
+ class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
70
+ />
71
+ </div>
72
+ )}
73
+ {!hasImage && hasIcon && (
74
+ <div class:list={['w-10 h-10 rounded-lg flex items-center justify-center shrink-0 transition-colors', c.iconBg]}>
75
+ <Icon name={item.icon!} class:list={['w-5 h-5', c.iconText]} />
76
+ </div>
77
+ )}
78
+ <div class:list={[
79
+ hasImage ? 'flex flex-col gap-2 p-5' : 'flex flex-col gap-1.5',
80
+ ]}>
81
+ {item.eyebrow && (
82
+ <span class="text-xs font-medium uppercase tracking-wider text-muted-foreground" aria-hidden="true">
83
+ {item.eyebrow}
84
+ </span>
85
+ )}
86
+ <span class:list={[
87
+ 'font-display font-semibold text-foreground transition-colors flex items-center gap-1.5',
88
+ c.text,
89
+ ]}>
90
+ {item.title}
91
+ <Icon name="arrow-right" class="w-3.5 h-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
92
+ </span>
93
+ {item.description && (
94
+ <span class="text-sm leading-relaxed text-muted-foreground" aria-hidden="true">
95
+ {item.description}
96
+ </span>
97
+ )}
98
+ </div>
99
+ </a>
@@ -7,3 +7,7 @@ export { default as InlineImage } from './InlineImage.astro';
7
7
  export { default as Pagination } from './Pagination.astro';
8
8
  export { default as EntityCard } from './EntityCard.astro';
9
9
  export { default as ArrowLinkGrid } from './ArrowLinkGrid.astro';
10
+ // RelatedContent sits here, not in ./editorial: it renders CardCarousel, whose <style> ships to every importer of the barrel.
11
+ export { default as RelatedContent } from './RelatedContent.astro';
12
+ export { default as RelatedContentCard } from './RelatedContentCard.astro';
13
+ export type { RelatedItem, RelatedTone } from './related';
@@ -0,0 +1,16 @@
1
+ import type { ImageMetadata } from 'astro';
2
+
3
+ export type RelatedTone = 'primary' | 'accent' | 'secondary';
4
+
5
+ /** A related-content card. Images are optimised imports, never public path strings. */
6
+ export interface RelatedItem {
7
+ href: string;
8
+ title: string;
9
+ description?: string;
10
+ eyebrow?: string;
11
+ /** A ui Icon name. */
12
+ icon?: string;
13
+ tone?: RelatedTone;
14
+ image?: ImageMetadata;
15
+ imageAlt?: string;
16
+ }
@@ -0,0 +1,34 @@
1
+ ---
2
+ /** Internal: an article card's byline — author, the published date or an "Updated" Badge, then the reading time. */
3
+ import Badge from '../primitives/Badge.astro';
4
+
5
+ interface Props {
6
+ author?: string;
7
+ dateTime?: string;
8
+ dateLabel?: string;
9
+ updated?: boolean;
10
+ updatedLabel: string;
11
+ readingLabel?: string;
12
+ class: string;
13
+ }
14
+
15
+ const { author, dateTime, dateLabel, updated = false, updatedLabel, readingLabel, class: className } = Astro.props;
16
+ ---
17
+
18
+ <p class={className}>
19
+ {author && <span>{author}</span>}
20
+ {author && dateTime && <span aria-hidden="true">·</span>}
21
+ {dateTime && (updated ? (
22
+ <Badge tone="custom" toneClass="bg-muted" size="sm" lines={0} class="![font-size:inherit] ![line-height:inherit] ![font-weight:inherit] !gap-1">
23
+ <svg class="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
24
+ <polyline points="23 4 23 10 17 10" />
25
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
26
+ </svg>
27
+ {updatedLabel}{' '}<time datetime={dateTime}>{dateLabel}</time>
28
+ </Badge>
29
+ ) : (
30
+ <time datetime={dateTime}>{dateLabel}</time>
31
+ ))}
32
+ {readingLabel && <span aria-hidden="true">·</span>}
33
+ {readingLabel && <span>{readingLabel}</span>}
34
+ </p>
@@ -0,0 +1,56 @@
1
+ ---
2
+ /**
3
+ * ArticleCard — one article in a grid: image, title, byline with the published or "Updated" date, description and
4
+ * a read link. Dates arrive formatted; the image is the route's import.
5
+ */
6
+ import { Picture } from 'astro:assets';
7
+ import ArticleByline from './ArticleByline.astro';
8
+ import Icon from '../primitives/Icon.astro';
9
+ import type { ArticleSummary } from './types';
10
+
11
+ interface Props {
12
+ article: ArticleSummary;
13
+ readLabel: string;
14
+ updatedLabel: string;
15
+ loading?: 'eager' | 'lazy';
16
+ /** Keep the card out of the search index (listings that are themselves indexed). */
17
+ pagefindIgnore?: boolean;
18
+ class?: string;
19
+ }
20
+
21
+ const { article: a, readLabel, updatedLabel, loading = 'lazy', pagefindIgnore = false, class: className } = Astro.props;
22
+ ---
23
+
24
+ <a
25
+ href={a.href}
26
+ data-pagefind-ignore={pagefindIgnore ? '' : undefined}
27
+ class:list={['group flex flex-col rounded-xl border border-border bg-card overflow-hidden hover:border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50', className]}
28
+ >
29
+ {a.image ? (
30
+ <div class="aspect-[4/3] overflow-hidden">
31
+ <Picture
32
+ src={a.image}
33
+ alt={a.imageAlt ?? ''}
34
+ widths={[320, 480, 640]}
35
+ sizes="(max-width: 640px) 100vw, 33vw"
36
+ formats={['avif', 'webp']}
37
+ fallbackFormat="webp"
38
+ class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
39
+ loading={loading}
40
+ />
41
+ </div>
42
+ ) : (
43
+ <div class="aspect-[4/3] bg-muted/40"></div>
44
+ )}
45
+ <div class="p-5 flex-1 flex flex-col">
46
+ <h3 class="text-base sm:text-lg font-semibold font-display leading-snug group-hover:text-primary transition-colors text-balance">
47
+ {a.title}
48
+ </h3>
49
+ <ArticleByline class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground" author={a.author} dateTime={a.dateTime} dateLabel={a.dateLabel} updated={a.updated} updatedLabel={updatedLabel} readingLabel={a.readingLabel} />
50
+ <p class="mt-2 text-sm text-muted-foreground leading-relaxed line-clamp-2">{a.description}</p>
51
+ <span class="inline-flex items-center gap-1 mt-auto pt-3 text-sm font-medium text-primary">
52
+ {readLabel}
53
+ <Icon name="arrow-right" class="w-3.5 h-3.5 motion-safe:transition-transform motion-safe:group-hover:translate-x-0.5" />
54
+ </span>
55
+ </div>
56
+ </a>
@@ -0,0 +1,50 @@
1
+ ---
2
+ /** ArticleCategoryNav — "All" plus one chip per populated topic, each with a count badge. The site resolves the links. */
3
+ import Chip from '../primitives/Chip.astro';
4
+ import Badge from '../primitives/Badge.astro';
5
+ import type { ArticleCategoryLink } from './types';
6
+
7
+ interface Props {
8
+ categories: ArticleCategoryLink[];
9
+ all: Omit<ArticleCategoryLink, 'icon'>;
10
+ ariaLabel: string;
11
+ class?: string;
12
+ }
13
+
14
+ const { categories, all, ariaLabel, class: className } = Astro.props;
15
+ const countClass = (active: boolean) => (active ? 'bg-overlay-foreground/20' : 'bg-muted');
16
+ ---
17
+
18
+ {categories.length > 0 && (
19
+ <nav aria-label={ariaLabel} class={className}>
20
+ <div class="flex gap-2.5 overflow-x-auto flex-nowrap pb-2 scrollbar-hide md:flex-wrap md:overflow-visible md:pb-0">
21
+ <Chip
22
+ href={all.href}
23
+ size="md"
24
+ tone="outline"
25
+ accent="primary"
26
+ active={all.active}
27
+ aria-current={all.active ? 'page' : undefined}
28
+ class="shrink-0"
29
+ >
30
+ {all.label}
31
+ <Badge tone="custom" toneClass={countClass(all.active)} size="sm" lines={0} class="!px-1.5 ![font-weight:inherit]">{all.count}</Badge>
32
+ </Chip>
33
+ {categories.map((c) => (
34
+ <Chip
35
+ href={c.href}
36
+ size="md"
37
+ tone="outline"
38
+ accent="primary"
39
+ active={c.active}
40
+ aria-current={c.active ? 'page' : undefined}
41
+ icon={c.icon}
42
+ class="shrink-0"
43
+ >
44
+ {c.label}
45
+ <Badge tone="custom" toneClass={countClass(c.active)} size="sm" lines={0} class="!px-1.5 ![font-weight:inherit]">{c.count}</Badge>
46
+ </Chip>
47
+ ))}
48
+ </div>
49
+ </nav>
50
+ )}
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { Picture } from 'astro:assets';
7
7
  import type { ImageMetadata } from 'astro';
8
- import Badge from '../primitives/Badge.astro';
8
+ import ArticleByline from './ArticleByline.astro';
9
9
  import Icon from '../primitives/Icon.astro';
10
10
 
11
11
  interface Props {
@@ -14,7 +14,7 @@ interface Props {
14
14
  description: string;
15
15
  author: string;
16
16
  /** "Featured" or "Latest". */
17
- label: string;
17
+ label?: string;
18
18
  image?: ImageMetadata;
19
19
  imageAlt?: string;
20
20
  /** The date shown: the modified one when `updated`, else the published one. */
@@ -23,12 +23,20 @@ interface Props {
23
23
  updated?: boolean;
24
24
  readLabel?: string;
25
25
  updatedLabel?: string;
26
+ /** Pre-composed reading-time text, e.g. "6 min read". */
27
+ readingLabel?: string;
28
+ /** Lazy for a card below the fold; eager (with high fetch priority) by default. */
29
+ loading?: 'eager' | 'lazy';
30
+ headingLevel?: 2 | 3;
31
+ pagefindIgnore?: boolean;
32
+ class?: string;
26
33
  }
27
34
 
28
- const { href, title, description, author, label, image, imageAlt = '', dateTime, dateLabel, updated = false, readLabel = 'Read article', updatedLabel = 'Updated' } = Astro.props;
35
+ const { href, title, description, author, label, image, imageAlt = '', dateTime, dateLabel, updated = false, readLabel = 'Read article', updatedLabel = 'Updated', readingLabel, loading = 'eager', headingLevel = 2, pagefindIgnore = false, class: className = 'mb-12' } = Astro.props;
36
+ const Heading = `h${headingLevel}` as 'h2' | 'h3';
29
37
  ---
30
38
 
31
- <a href={href} class="group block mb-12 rounded-xl border border-border bg-card overflow-hidden hover:border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50">
39
+ <a href={href} data-pagefind-ignore={pagefindIgnore ? '' : undefined} class:list={['group block', className, 'rounded-xl border border-border bg-card overflow-hidden hover:border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50']}>
32
40
  <div class="grid sm:grid-cols-[1.2fr_1fr] gap-0">
33
41
  {image ? (
34
42
  <div class="aspect-[4/3] sm:aspect-auto overflow-hidden">
@@ -40,33 +48,19 @@ const { href, title, description, author, label, image, imageAlt = '', dateTime,
40
48
  formats={['avif', 'webp']}
41
49
  fallbackFormat="webp"
42
50
  class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
43
- loading="eager"
44
- fetchpriority="high"
51
+ loading={loading}
52
+ fetchpriority={loading === 'eager' ? 'high' : undefined}
45
53
  />
46
54
  </div>
47
55
  ) : (
48
56
  <div class="aspect-[4/3] sm:aspect-auto bg-muted/40"></div>
49
57
  )}
50
58
  <div class="p-6 sm:p-8 flex flex-col justify-center">
51
- <p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-2">{label}</p>
52
- <h2 class="text-xl sm:text-2xl font-semibold font-display text-foreground leading-snug group-hover:text-primary transition-colors text-balance">
59
+ {label && <p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-2">{label}</p>}
60
+ <Heading class="text-xl sm:text-2xl font-semibold font-display text-foreground leading-snug group-hover:text-primary transition-colors text-balance">
53
61
  {title}
54
- </h2>
55
- <p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
56
- <span>{author}</span>
57
- <span aria-hidden="true">·</span>
58
- {updated ? (
59
- <Badge tone="custom" toneClass="bg-muted" size="sm" lines={0} class="![font-size:inherit] ![line-height:inherit] ![font-weight:inherit] !gap-1">
60
- <svg class="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
61
- <polyline points="23 4 23 10 17 10" />
62
- <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
63
- </svg>
64
- {updatedLabel}{' '}<time datetime={dateTime}>{dateLabel}</time>
65
- </Badge>
66
- ) : (
67
- <time datetime={dateTime}>{dateLabel}</time>
68
- )}
69
- </p>
62
+ </Heading>
63
+ <ArticleByline class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground" author={author} dateTime={dateTime} dateLabel={dateLabel} updated={updated} updatedLabel={updatedLabel} readingLabel={readingLabel} />
70
64
  <p class="mt-3 text-sm text-muted-foreground leading-relaxed line-clamp-3">{description}</p>
71
65
  <span class="inline-flex items-center gap-1 mt-5 text-sm font-medium text-primary">
72
66
  {readLabel}
@@ -0,0 +1,23 @@
1
+ ---
2
+ /** ArticleGrid — a grid of ArticleCards; renders nothing for an empty list. */
3
+ import ArticleCard from './ArticleCard.astro';
4
+ import type { ArticleSummary } from './types';
5
+
6
+ interface Props {
7
+ articles: ArticleSummary[];
8
+ readLabel: string;
9
+ updatedLabel: string;
10
+ pagefindIgnore?: boolean;
11
+ class?: string;
12
+ }
13
+
14
+ const { articles, readLabel, updatedLabel, pagefindIgnore = false, class: className = 'grid gap-6 sm:grid-cols-2' } = Astro.props;
15
+ ---
16
+
17
+ {articles.length > 0 && (
18
+ <div class={className}>
19
+ {articles.map((article) => (
20
+ <ArticleCard article={article} readLabel={readLabel} updatedLabel={updatedLabel} pagefindIgnore={pagefindIgnore} />
21
+ ))}
22
+ </div>
23
+ )}
@@ -0,0 +1,54 @@
1
+ ---
2
+ /**
3
+ * ArticleKeyFacts — the above-the-fold "3 quick facts" strip: figure-first tiles in a semantic <dl>, rendered only
4
+ * with 3+ items. Not composed/QuickFacts: that one is label-above-value, needs a title and a per-item icon.
5
+ * Palette classes are props so a site keeps its own figure and surface roles.
6
+ */
7
+ import type { KeyFact } from './types';
8
+
9
+ interface Props {
10
+ items: KeyFact[];
11
+ ariaLabel: string;
12
+ borderClass?: string;
13
+ surfaceClass?: string;
14
+ labelClass?: string;
15
+ figureClass?: string;
16
+ class?: string;
17
+ }
18
+
19
+ const {
20
+ items,
21
+ ariaLabel,
22
+ borderClass = 'border-accent/25',
23
+ surfaceClass = 'bg-card',
24
+ labelClass = 'tracking-[0.14em] text-foreground/70',
25
+ figureClass = 'text-primary',
26
+ class: className = '',
27
+ } = Astro.props;
28
+ ---
29
+
30
+ {items.length >= 3 && (
31
+ <dl
32
+ class:list={[
33
+ // gap-px over a bg-border grid draws the dividers as cell edges, never a side-stripe border.
34
+ `grid grid-cols-1 gap-px overflow-hidden rounded-xl border ${borderClass} bg-border sm:grid-cols-3`,
35
+ className,
36
+ ]}
37
+ aria-label={ariaLabel}
38
+ >
39
+ {items.map((fact) => (
40
+ <div class={`flex min-w-0 flex-col ${surfaceClass} px-5 py-5 sm:px-6`}>
41
+ {/* dt precedes dd in the DOM; order-* puts the figure first visually. */}
42
+ <dt class={`order-2 mt-2 text-[0.8125rem] font-semibold uppercase leading-none ${labelClass}`}>
43
+ {fact.label}
44
+ </dt>
45
+ <dd class={`order-1 m-0 font-display text-[2rem] leading-none tracking-[-0.02em] tabular-nums ${figureClass} sm:text-[2.25rem]`}>
46
+ {fact.figure}
47
+ </dd>
48
+ {fact.detailHtml && (
49
+ <dd class="order-3 m-0 mt-2 text-sm leading-snug text-muted-foreground" set:html={fact.detailHtml} />
50
+ )}
51
+ </div>
52
+ ))}
53
+ </dl>
54
+ )}