@sarimarcus/content-sites-ui 0.41.2 → 0.42.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,20 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.42.0 (2026-10-21)
6
+
7
+ - `editorial`: `PersonPage` is rebuilt biography first: a `ContentHero` (warm, editorial overlay, short) with the role badges and `EraChip` in its badges slot, a lifespan line ("Lived c. 1138–1204 (about 66 years)", or "Born"/"Died" with one known year) and a monogram panel when there is no image; a key-facts strip (aliases, optional `contextFact`); the bio with a lead first paragraph at a 65ch measure; a `TableOfContents` only past four bio paragraphs; a featured full-story card (`fullStory.title`, else the old link line); places to see; the era timeline; then read more, capped at `readMoreLimit` (default 4). New optional props: `birthYear`, `deathYear`, `circa`, `lifespan`, `aliases`, `aliasesLabel`, `contextFact`, `monogram`, `fullStoryHeading`, `readMoreLimit`, `tocLabels`; `fullStory` gains `title`, `summary`, `image`, `fallbackSrc`, `imageAlt`. `PersonCard` gains `knownFor` (replaces the description line), `eraLabel` and a monogram tile when there is no image. New `monogram()` helper, internal `PersonSection` and `CardPlaceholder`; `RoleBadge` and `EraChip` gain `onImage`. `composed`: `QuickFacts` gains `variant="strip"`, an optional `icon` and `pagefindIgnore`; its other variants render identically. `layout`: `ContentHero` gains `monogram`, a dark panel with the initials when no image is set; existing heroes render identically. Copy defaults in `LABELS.personPage` (`lifespan`, `born`, `died`, `alsoKnownAs`, `fullStoryHeading`, `tocLabels`) (SLA-3290).
8
+
9
+ ### Breaking
10
+
11
+ - `PersonPage`'s markup and section order change: after-body now renders before the full-story block, the timeline renders before read more, the bio is always wrapped in `<section data-person-section="biography">`, and the section ids are `person-<section>` with `person-<section>-heading` headings. `readMore` renders only its first four entries unless `readMoreLimit` says otherwise, so a route passing many cards should rank them first. No site selects on the old `data-person-*` contract and no site edit is needed, so there is no codemod.
12
+
13
+ ## 0.41.3 (2026-10-21)
14
+
15
+ - `editorial`: new `PersonChip` (a Chip link to a historical figure's page, name and life dates), `PersonCard` (an `EntityCard` poster teaser with portrait, dates, role badges and a clamped description) and `PersonPage` (the person page shell: portrait, roles, name, dates with the `EraChip`, short bio, "Read the full story" link to the primary article, places to see and related reading as `RelatedContentGrid`s, a `fallbackSrc` prop for a string `image`, and the era on a `ChronologyTimeline`; slots `breadcrumb`, `before-body`, `after-body`, `tail`). Copy defaults live in `LABELS.personChip`, `personCard` and `personPage`. No change to existing components' markup (SLA-3260).
16
+ - `composed`: new `RelatedContentGrid` (the cards-only responsive grid of `RelatedContent`, no heading, surface or carousel CSS); `RelatedContent` renders it for `layout="grid"` with identical HTML.
17
+ - `primitives`: `Badge` gains `tone="outline"` (bordered, muted fill); the role and expertise pills of `PersonCard`, `PersonPage` and `AuthorCard` share it through an internal `RoleBadge`, with `AuthorCard`'s markup unchanged. `PersonPage` now requires `imageAlt` whenever `image` or `fallbackSrc` is set.
18
+
5
19
  ## 0.41.2 (2026-10-21)
6
20
 
7
21
  - `editorial`: `ChronologyTimeline`'s era index is now `TableOfContents` (card variant) instead of a row of chips: a collapsed jump list on mobile, a sticky bar once the index scrolls away, and the era in view marked with `data-active` / `aria-current`. The era anchor ids move from the era heading to its `<li>`, so the scrollspy follows the whole era and the sticky bar hides after the last one. Its markup changes on every page that renders the timeline (SLA-3274).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.41.2",
3
+ "version": "0.42.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",
@@ -50,6 +50,6 @@
50
50
  "@lucide/astro": "^1.23.0",
51
51
  "astro": "^7.0.6",
52
52
  "tailwindcss": "^4.1.18",
53
- "@sarimarcus/content-sites-core": "0.41.2"
53
+ "@sarimarcus/content-sites-core": "0.42.0"
54
54
  }
55
55
  }
@@ -25,7 +25,7 @@ import Icon from '../primitives/Icon.astro';
25
25
  import type { BrandTone } from '../types';
26
26
 
27
27
  interface QuickFactItem {
28
- icon: string;
28
+ icon?: string;
29
29
  label: string;
30
30
  value: string;
31
31
  href?: string;
@@ -44,12 +44,15 @@ interface ActionLink {
44
44
  interface Props {
45
45
  title?: string;
46
46
  items: QuickFactItem[];
47
- variant?: 'card' | 'muted';
47
+ /** `strip`: borderless rows divided by hairlines, label beside value on `sm`, for a quiet facts line. */
48
+ variant?: 'card' | 'muted' | 'strip';
48
49
  columns?: 2 | 3 | 4;
49
50
  links?: ActionLink[];
50
51
  headingLevel?: 'h2' | 'h3' | 'h4';
51
52
  /** Replaces the fact labels' size class. */
52
53
  labelSizeClass?: string;
54
+ /** `false` lets Pagefind index the facts (default: ignored). */
55
+ pagefindIgnore?: boolean;
53
56
  class?: string;
54
57
  }
55
58
 
@@ -61,16 +64,22 @@ const {
61
64
  links,
62
65
  headingLevel = 'h2',
63
66
  labelSizeClass = 'text-[0.8125rem]',
67
+ pagefindIgnore = true,
64
68
  class: className = '',
65
69
  } = Astro.props;
66
70
 
67
71
  const Heading = headingLevel;
68
72
 
69
- const containerClass = variant === 'card'
70
- ? 'p-6 lg:p-8 bg-card rounded-2xl border border-accent/20'
71
- : 'p-6 bg-primary/5 rounded-xl border border-primary/15';
73
+ const strip = variant === 'strip';
74
+ const containerClass = strip
75
+ ? ''
76
+ : variant === 'card'
77
+ ? 'p-6 lg:p-8 bg-card rounded-2xl border border-accent/20'
78
+ : 'p-6 bg-primary/5 rounded-xl border border-primary/15';
72
79
 
73
- const gridClass = columns === 4
80
+ const gridClass = strip
81
+ ? 'divide-y divide-border border-y border-border'
82
+ : columns === 4
74
83
  ? 'grid sm:grid-cols-2 lg:grid-cols-4 gap-6'
75
84
  : columns === 3
76
85
  ? 'grid sm:grid-cols-2 lg:grid-cols-3 gap-6'
@@ -78,9 +87,13 @@ const gridClass = columns === 4
78
87
 
79
88
  const titleClass = 'text-2xl font-display font-semibold text-foreground mb-6 leading-tight';
80
89
 
81
- const labelClass = `${labelSizeClass} uppercase tracking-wider text-muted-foreground mb-1.5 leading-none`;
90
+ const labelClass = strip
91
+ ? 'text-sm font-semibold text-muted-foreground'
92
+ : `${labelSizeClass} uppercase tracking-wider text-muted-foreground mb-1.5 leading-none`;
82
93
 
83
- const valueClass = variant === 'card'
94
+ const valueClass = strip
95
+ ? 'text-foreground'
96
+ : variant === 'card'
84
97
  ? 'text-lg font-medium text-foreground leading-snug'
85
98
  : 'text-base font-medium text-foreground leading-snug';
86
99
 
@@ -92,14 +105,14 @@ const linkColorMap: Record<string, { bg: string; hover: string; text: string }>
92
105
  ---
93
106
 
94
107
  {items.length > 0 && (
95
- <div class:list={[containerClass, className]} data-pagefind-ignore>
108
+ <div class:list={[containerClass, className]} data-pagefind-ignore={pagefindIgnore ? '' : undefined}>
96
109
  {title && <Heading class={titleClass}>{title}</Heading>}
97
110
 
98
111
  <dl class={gridClass}>
99
112
  {items.map((item) => (
100
- <div class:list={['min-w-0', item.span === 2 && 'sm:col-span-2']}>
113
+ <div class:list={['min-w-0', item.span === 2 && 'sm:col-span-2', strip && 'grid gap-1 py-3 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-6']}>
101
114
  <dt class={`${labelClass} flex items-center gap-1.5`}>
102
- <Icon name={item.icon} class="w-3.5 h-3.5 text-primary/60" />
115
+ {item.icon && <Icon name={item.icon} class="w-3.5 h-3.5 text-primary/60" />}
103
116
  {item.label}
104
117
  </dt>
105
118
  <dd class={`${valueClass} tabular-nums break-words`}>
@@ -7,6 +7,7 @@ import ArrowLabel from '../primitives/ArrowLabel.astro';
7
7
  import SectionDivider from '../primitives/SectionDivider.astro';
8
8
  import CardCarousel from './CardCarousel.astro';
9
9
  import RelatedContentCard from './RelatedContentCard.astro';
10
+ import RelatedContentGrid from './RelatedContentGrid.astro';
10
11
  import type { RelatedItem } from './related';
11
12
 
12
13
  interface Props {
@@ -61,10 +62,6 @@ const wrapperClass = variant === 'bordered'
61
62
  ? ''
62
63
  : 'p-6 bg-muted/50 rounded-xl border border-border';
63
64
 
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
65
  // At `lg` a card fits `columns` per view, so the track stops overflowing and the controls hide. Percentages, not
69
66
  // fixed widths: the block usually sits inside a ~700px prose column. Below `lg` cards are wider than a column share
70
67
  // so the next one peeks in.
@@ -72,13 +69,9 @@ const cardWidthClass = columns === 3
72
69
  ? 'w-[80vw] sm:w-64 lg:w-[calc((100%-2rem)/3)]'
73
70
  : 'w-[85vw] sm:w-80 lg:w-[calc((100%-1rem)/2)]';
74
71
 
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'));
72
+ const carouselPictureSizes = pictureSizesProp ?? (columns === 3
73
+ ? '(min-width: 1024px) 240px, (min-width: 640px) 256px, 80vw'
74
+ : '(min-width: 640px) 320px, 85vw');
82
75
 
83
76
  // CardCarousel's contract: every direct child of the track is one card with `snap-start shrink-0` and a width.
84
77
  const carouselShell = `shrink-0 snap-start ${cardWidthClass}`;
@@ -101,7 +94,7 @@ const carouselShell = `shrink-0 snap-start ${cardWidthClass}`;
101
94
  <RelatedContentCard
102
95
  item={item}
103
96
  shellClass={carouselShell}
104
- pictureSizes={pictureSizes}
97
+ pictureSizes={carouselPictureSizes}
105
98
  trackingBlock={trackingBlock}
106
99
  />
107
100
  ))}
@@ -121,15 +114,12 @@ const carouselShell = `shrink-0 snap-start ${cardWidthClass}`;
121
114
  )}
122
115
 
123
116
  {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>
117
+ <RelatedContentGrid
118
+ items={items}
119
+ columns={columns}
120
+ pictureSizes={pictureSizesProp}
121
+ trackingBlock={trackingBlock}
122
+ />
133
123
  )}
134
124
  </Wrapper>
135
125
  )}
@@ -0,0 +1,32 @@
1
+ ---
2
+ /**
3
+ * RelatedContentGrid — related link cards as a responsive grid, with no heading, surface or carousel. `RelatedContent`
4
+ * renders it for `layout="grid"`; a page that owns its own heading uses it directly.
5
+ */
6
+ import RelatedContentCard from './RelatedContentCard.astro';
7
+ import type { RelatedItem } from './related';
8
+
9
+ interface Props {
10
+ items: readonly RelatedItem[];
11
+ columns?: 2 | 3;
12
+ trackingBlock?: string;
13
+ /** Image `sizes`; the defaults match the column count in a ~700px prose column. */
14
+ pictureSizes?: string;
15
+ }
16
+
17
+ const { items, columns = 2, trackingBlock, pictureSizes: pictureSizesProp } = Astro.props;
18
+
19
+ const gridClass = columns === 3
20
+ ? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
21
+ : 'grid gap-4 sm:grid-cols-2';
22
+
23
+ const pictureSizes = pictureSizesProp ?? (columns === 3
24
+ ? '(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw'
25
+ : '(min-width: 640px) 50vw, 100vw');
26
+ ---
27
+
28
+ <div class={gridClass}>
29
+ {items.map((item) => (
30
+ <RelatedContentCard item={item} pictureSizes={pictureSizes} trackingBlock={trackingBlock} />
31
+ ))}
32
+ </div>
@@ -10,4 +10,6 @@ export { default as EntityCard } from './EntityCard.astro';
10
10
  export { default as ArrowLinkGrid } from './ArrowLinkGrid.astro';
11
11
  // RelatedContent sits here, not in ./editorial: it renders CardCarousel, whose <style> ships to every importer of the barrel.
12
12
  export { default as RelatedContent } from './RelatedContent.astro';
13
- export { default as RelatedContentCard } from './RelatedContentCard.astro';export type { RelatedItem, RelatedTone } from './related';
13
+ export { default as RelatedContentCard } from './RelatedContentCard.astro';
14
+ export { default as RelatedContentGrid } from './RelatedContentGrid.astro';
15
+ export type { RelatedItem, RelatedTone } from './related';
@@ -4,7 +4,7 @@
4
4
  * (`href`), a centred polaroid portrait (`polaroid`), or a plain card with a profile link. `roleLabel` is free text
5
5
  * whose meaning is the caller's: a job title on one page, "Curated by" on another.
6
6
  */
7
- import Badge from '../primitives/Badge.astro';
7
+ import RoleBadge from './RoleBadge.astro';
8
8
  import LinkArrow from '../primitives/LinkArrow.astro';
9
9
  import ArrowLabel from '../primitives/ArrowLabel.astro';
10
10
 
@@ -79,9 +79,9 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3';
79
79
 
80
80
  <div class="mt-5 flex flex-wrap gap-2">
81
81
  {expertise.map((topic) => (
82
- <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
82
+ <RoleBadge>
83
83
  {topic}
84
- </Badge>
84
+ </RoleBadge>
85
85
  ))}
86
86
  </div>
87
87
 
@@ -123,9 +123,9 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3';
123
123
 
124
124
  <div class="mt-5 flex flex-wrap justify-center gap-2 lg:justify-start">
125
125
  {expertise.map((topic) => (
126
- <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
126
+ <RoleBadge>
127
127
  {topic}
128
- </Badge>
128
+ </RoleBadge>
129
129
  ))}
130
130
  </div>
131
131
 
@@ -159,9 +159,9 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3';
159
159
 
160
160
  <div class="mt-5 flex flex-wrap gap-2">
161
161
  {expertise.map((topic) => (
162
- <Badge tone="custom" toneClass="border bg-muted/50 text-foreground" size="md" lines={0} class="border-border">
162
+ <RoleBadge>
163
163
  {topic}
164
- </Badge>
164
+ </RoleBadge>
165
165
  ))}
166
166
  </div>
167
167
 
@@ -0,0 +1,7 @@
1
+ ---
2
+ // The EntityCard `fallback` slot tile for a card with no image: tinted panel, centred content (initials or an icon).
3
+ ---
4
+
5
+ <div class="absolute inset-0 flex items-center justify-center bg-primary/10" aria-hidden="true">
6
+ <slot />
7
+ </div>
@@ -15,6 +15,8 @@ interface Props {
15
15
  /** Accessible name; keep the visible label and range in it. */
16
16
  ariaLabel?: (era: EraDefinition) => string;
17
17
  icon?: string;
18
+ /** Opaque scrim tone for a chip laid over a photo or the hero overlay. */
19
+ onImage?: boolean;
18
20
  class?: string;
19
21
  }
20
22
 
@@ -23,6 +25,7 @@ const {
23
25
  basePath,
24
26
  ariaLabel = (e: EraDefinition) => LABELS.eraChip.ariaLabel(e.label, e.rangeLabel),
25
27
  icon = 'history',
28
+ onImage = false,
26
29
  class: className,
27
30
  }: Props = Astro.props;
28
31
  ---
@@ -32,7 +35,7 @@ const {
32
35
  as="a"
33
36
  href={eraHref(era, basePath)}
34
37
  size="md"
35
- tone="tint"
38
+ tone={onImage ? 'onImage' : 'tint'}
36
39
  accent="primary"
37
40
  icon={icon}
38
41
  data-era={era.id}
@@ -0,0 +1,99 @@
1
+ ---
2
+ /**
3
+ * PersonCard — a historical figure's teaser card on EntityCard's poster frame: portrait, name, life dates, era label,
4
+ * role labels and a one-line "known for" hook. Every string arrives as a prop (roles are the site's display labels);
5
+ * `ctaLabel` falls back to LABELS. Without `image` or `fallbackSrc` the image block is a monogram tile (initials of
6
+ * the name, or `monogram`). `eraLabel` is plain text: the card is one link, so it cannot hold the linked EraChip.
7
+ */
8
+ import type { ImageMetadata } from 'astro';
9
+ import EntityCard from '../composed/EntityCard.astro';
10
+ import RoleBadge from './RoleBadge.astro';
11
+ import ArrowLabel from '../primitives/ArrowLabel.astro';
12
+ import Badge from '../primitives/Badge.astro';
13
+ import { monogram as initialsOf } from './monogram';
14
+ import CardPlaceholder from './CardPlaceholder.astro';
15
+ import { LABELS } from '../internal/labels';
16
+
17
+ interface Props {
18
+ href: string;
19
+ name: string;
20
+ image?: ImageMetadata;
21
+ fallbackSrc?: string;
22
+ imageAlt: string;
23
+ /** Life dates as displayed, e.g. core `formatLifeDates`. */
24
+ dates?: string;
25
+ roles?: readonly string[];
26
+ /** One-line hook, e.g. "Physician and philosopher of Averroism". Wins over `description`. */
27
+ knownFor?: string;
28
+ /** Era as displayed, e.g. "Almohad Caliphate · 1121–1269". */
29
+ eraLabel?: string;
30
+ /** Tile initials when there is no image; defaults to the initials of `name`. */
31
+ monogram?: string;
32
+ /** Deprecated: clamped fallback when `knownFor` is absent. */
33
+ description?: string;
34
+ ctaLabel?: string;
35
+ headingLevel?: 2 | 3;
36
+ index?: number;
37
+ eager?: number;
38
+ sizes?: string;
39
+ class?: string;
40
+ }
41
+
42
+ const {
43
+ href,
44
+ name,
45
+ image,
46
+ fallbackSrc,
47
+ imageAlt,
48
+ dates,
49
+ roles = [],
50
+ knownFor,
51
+ eraLabel,
52
+ monogram,
53
+ description,
54
+ ctaLabel = LABELS.personCard.cta,
55
+ headingLevel = 3,
56
+ index,
57
+ eager,
58
+ sizes,
59
+ class: className,
60
+ } = Astro.props;
61
+
62
+ const Heading = `h${headingLevel}` as 'h2' | 'h3';
63
+ const initials = monogram ?? initialsOf(name);
64
+ ---
65
+
66
+ <EntityCard
67
+ href={href}
68
+ variant="poster"
69
+ image={image}
70
+ fallbackSrc={fallbackSrc}
71
+ imageAlt={imageAlt}
72
+ index={index}
73
+ eager={eager}
74
+ sizes={sizes}
75
+ footerJustify="end"
76
+ class={className}
77
+ >
78
+ {initials && (
79
+ <CardPlaceholder slot="fallback">
80
+ <span class="font-display text-6xl font-semibold text-primary/70 select-none">{initials}</span>
81
+ </CardPlaceholder>
82
+ )}
83
+ <Heading class="text-xl font-display font-semibold text-foreground">{name}</Heading>
84
+ {dates && <p class="mt-1 text-sm tabular-nums text-muted-foreground">{dates}</p>}
85
+ {(eraLabel || roles.length > 0) && (
86
+ <div class="mt-3 flex flex-wrap gap-2">
87
+ {eraLabel && <Badge tone="primary" size="md" lines={0}><span class="tabular-nums">{eraLabel}</span></Badge>}
88
+ {roles.map((role) => (
89
+ <RoleBadge>{role}</RoleBadge>
90
+ ))}
91
+ </div>
92
+ )}
93
+ {knownFor ? (
94
+ <p class="mt-3 text-sm leading-relaxed text-foreground">{knownFor}</p>
95
+ ) : description ? (
96
+ <p class="mt-3 text-sm leading-relaxed text-muted-foreground line-clamp-3">{description}</p>
97
+ ) : null}
98
+ <ArrowLabel slot="footer-cta" class="text-sm font-medium text-primary">{ctaLabel}</ArrowLabel>
99
+ </EntityCard>
@@ -0,0 +1,44 @@
1
+ ---
2
+ /**
3
+ * PersonChip — links an item to a historical figure's page. Visible text is `name` and, when given, the life `dates`
4
+ * (core `formatLifeDates`), within the Chip 80-char ceiling (validate-badge-overflow.mjs). Renders nothing without
5
+ * a name or an href.
6
+ */
7
+ import Chip from '../primitives/Chip.astro';
8
+ import { LABELS } from '../internal/labels';
9
+
10
+ interface Props {
11
+ name?: string;
12
+ href?: string;
13
+ dates?: string;
14
+ /** Accessible name; keep the visible name and dates in it. */
15
+ ariaLabel?: (name: string, dates?: string) => string;
16
+ icon?: string;
17
+ class?: string;
18
+ }
19
+
20
+ const {
21
+ name,
22
+ href,
23
+ dates,
24
+ ariaLabel = LABELS.personChip.ariaLabel,
25
+ icon = 'user',
26
+ class: className,
27
+ }: Props = Astro.props;
28
+ ---
29
+
30
+ {name && href && (
31
+ <Chip
32
+ as="a"
33
+ href={href}
34
+ size="md"
35
+ tone="tint"
36
+ accent="primary"
37
+ icon={icon}
38
+ data-person-chip
39
+ aria-label={ariaLabel(name, dates)}
40
+ class={className}
41
+ >
42
+ {name}{dates && (<><span aria-hidden="true">·</span><span class="whitespace-nowrap tabular-nums">{dates}</span></>)}
43
+ </Chip>
44
+ )}
@@ -0,0 +1,280 @@
1
+ ---
2
+ /**
3
+ * PersonPage — the shell and shared body of a historical figure's page, biography first. The route keeps paths, data,
4
+ * SEO and schema (core `historicalPersonSchema`), renders markdown to `bioHtml`, and wraps this template in the site
5
+ * Layout. Order: the `ContentHero` (warm, editorial overlay, short: name as title, role badges and the era chip,
6
+ * the lifespan as typography; portrait as the backdrop, else a monogram panel), a quiet key-facts strip (aliases,
7
+ * optional `contextFact`), a short sticky ToC only when the bio runs past four paragraphs, the bio with its first
8
+ * paragraph as the lead, the featured full-story card, places to see, the era on a ChronologyTimeline, then up to
9
+ * four related reads (`RelatedContentGrid`, not `RelatedContent`: the person page owns its headings and
10
+ * `RelatedContent` carries the carousel CSS). Every string is a prop or a LABELS default; a block with no data
11
+ * renders nothing. The lifespan reads "Lived c. 1138–1204 (about 66 years)" from `birthYear`/`deathYear`/`circa`,
12
+ * or from a pre-formatted `lifespan` or `dates` (both shown as is). `readMore` is ranked by the caller: the first
13
+ * `readMoreLimit` (four) render, so the route passes the best reads first. Slots:
14
+ * breadcrumb — the site Breadcrumb, in the band above the hero.
15
+ * before-body — site blocks between the key facts and the bio.
16
+ * after-body — site blocks after the bio, before the full-story card.
17
+ * tail — full-width blocks after the last section.
18
+ */
19
+ import type { ImageMetadata } from 'astro';
20
+ import type { EraDefinition } from '@sarimarcus/content-sites-core/eras';
21
+ import { lifeSpan } from '@sarimarcus/content-sites-core/people';
22
+ import Container from '../layout/Container.astro';
23
+ import BreadcrumbBand from '../layout/BreadcrumbBand.astro';
24
+ import ContentHero from '../layout/ContentHero.astro';
25
+ import RoleBadge from './RoleBadge.astro';
26
+ import LinkArrow from '../primitives/LinkArrow.astro';
27
+ import ArrowLabel from '../primitives/ArrowLabel.astro';
28
+ import Icon from '../primitives/Icon.astro';
29
+ import EntityCard from '../composed/EntityCard.astro';
30
+ import TableOfContents from '../composed/TableOfContents.astro';
31
+ import RelatedContentGrid from '../composed/RelatedContentGrid.astro';
32
+ import type { RelatedItem } from '../composed/related';
33
+ import type { TrustedHtml } from '../types';
34
+ import EraChip from './EraChip.astro';
35
+ import ChronologyTimeline from './ChronologyTimeline.astro';
36
+ import type { TimelineItem } from './chronology';
37
+ import { monogram as initialsOf } from './monogram';
38
+ import PersonSection from './PersonSection.astro';
39
+ import CardPlaceholder from './CardPlaceholder.astro';
40
+ import QuickFacts from '../composed/QuickFacts.astro';
41
+ import { LABELS } from '../internal/labels';
42
+
43
+ type PersonImage =
44
+ | { image?: undefined; fallbackSrc?: undefined; imageAlt?: string }
45
+ | {
46
+ image: ImageMetadata;
47
+ /** Plain path when the collection stores `image` as a string (no asset import). */
48
+ fallbackSrc?: string;
49
+ imageAlt: string;
50
+ }
51
+ | { image?: ImageMetadata; fallbackSrc: string; imageAlt: string };
52
+
53
+ type Props = PersonImage & {
54
+ name: string;
55
+ /** Life years as displayed, e.g. core `formatLifeDates`; used when no `birthYear`/`deathYear` is given. */
56
+ dates?: string;
57
+ birthYear?: number;
58
+ deathYear?: number;
59
+ /** Marks the years as approximate ("c."). */
60
+ circa?: boolean;
61
+ /** The whole lifespan line, shown as is (wins over the computed one). */
62
+ lifespan?: string;
63
+ /** Other names, as displayed. */
64
+ aliases?: readonly string[];
65
+ aliasesLabel?: string;
66
+ /** One extra key-facts row, e.g. the person's place in the city's history. */
67
+ contextFact?: { label: string; value: string };
68
+ /** Tile initials when there is no portrait; defaults to the initials of `name`. */
69
+ monogram?: string;
70
+ /** Role labels as displayed. */
71
+ roles?: readonly string[];
72
+ era?: EraDefinition;
73
+ eraBasePath?: string;
74
+ bioHtml: TrustedHtml;
75
+ /** Prose modifiers after `prose`. */
76
+ proseClass?: string;
77
+ /**
78
+ * The person's primary article. With a `title` it renders as a featured card under `fullStoryHeading`; without,
79
+ * as a link line (`label` falls back to LABELS).
80
+ */
81
+ fullStory?: {
82
+ href: string;
83
+ label?: string;
84
+ title?: string;
85
+ summary?: string;
86
+ image?: ImageMetadata;
87
+ fallbackSrc?: string;
88
+ imageAlt?: string;
89
+ };
90
+ fullStoryHeading?: string;
91
+ places?: readonly RelatedItem[];
92
+ placesHeading?: string;
93
+ /** Caller-ranked: the first `readMoreLimit` render. */
94
+ readMore?: readonly RelatedItem[];
95
+ /** Cards shown under read more. */
96
+ readMoreLimit?: number;
97
+ readMoreHeading?: string;
98
+ /** The era in context: pass the person's era only, or the whole chronology. */
99
+ timeline?: { eras: readonly EraDefinition[]; items: Record<string, readonly TimelineItem[]>; heading?: string };
100
+ /** Overrides of the ToC pill labels. */
101
+ tocLabels?: Partial<Record<'biography' | 'places' | 'timeline' | 'readMore', string>>;
102
+ pagefindType?: string;
103
+ };
104
+
105
+ const {
106
+ name,
107
+ dates,
108
+ birthYear,
109
+ deathYear,
110
+ circa = false,
111
+ lifespan,
112
+ aliases = [],
113
+ aliasesLabel = LABELS.personPage.alsoKnownAs,
114
+ contextFact,
115
+ monogram,
116
+ roles = [],
117
+ image,
118
+ fallbackSrc,
119
+ imageAlt,
120
+ era,
121
+ eraBasePath,
122
+ bioHtml,
123
+ proseClass = 'prose-lg',
124
+ fullStory,
125
+ fullStoryHeading = LABELS.personPage.fullStoryHeading,
126
+ places = [],
127
+ placesHeading = LABELS.personPage.placesHeading,
128
+ readMore = [],
129
+ readMoreLimit = 4,
130
+ readMoreHeading = LABELS.personPage.readMoreHeading,
131
+ timeline,
132
+ tocLabels,
133
+ pagefindType = 'person',
134
+ } = Astro.props;
135
+
136
+ const timelineHeading = timeline?.heading ?? LABELS.personPage.timelineHeading;
137
+ const hasTimeline = Boolean(timeline && Object.values(timeline.items).some((list) => list.length > 0));
138
+ const readMoreItems = readMore.slice(0, readMoreLimit);
139
+
140
+ const span = lifeSpan({ birthYear, deathYear });
141
+ const lifespanByKind = {
142
+ range: () => LABELS.personPage.lifespan(span.years ?? '', circa, span.age),
143
+ born: () => LABELS.personPage.born(span.years ?? '', circa),
144
+ died: () => LABELS.personPage.died(span.years ?? '', circa),
145
+ none: () => undefined,
146
+ };
147
+ const derivedLifespan = lifespanByKind[span.kind]();
148
+ const lifespanText = lifespan ?? derivedLifespan ?? dates;
149
+
150
+ const facts = [
151
+ ...(aliases.length > 0 ? [{ label: aliasesLabel, value: aliases.join(', ') }] : []),
152
+ ...(contextFact ? [contextFact] : []),
153
+ ];
154
+
155
+ const hasFullStoryCard = Boolean(fullStory?.title);
156
+ const bioParagraphs = (String(bioHtml).match(/<p[\s>]/g) ?? []).length;
157
+ const toc = {
158
+ biography: tocLabels?.biography ?? LABELS.personPage.tocLabels.biography,
159
+ places: tocLabels?.places ?? LABELS.personPage.tocLabels.places,
160
+ timeline: tocLabels?.timeline ?? LABELS.personPage.tocLabels.timeline,
161
+ readMore: tocLabels?.readMore ?? LABELS.personPage.tocLabels.readMore,
162
+ };
163
+ const showToc = bioParagraphs > 4;
164
+ const tocItems = [
165
+ { label: toc.biography, href: '#person-biography' },
166
+ ...(places.length > 0 ? [{ label: toc.places, href: '#person-places' }] : []),
167
+ ...(hasTimeline ? [{ label: toc.timeline, href: '#person-timeline' }] : []),
168
+ ...(readMoreItems.length > 0 ? [{ label: toc.readMore, href: '#person-read-more' }] : []),
169
+ ];
170
+ const heroMonogram = image || fallbackSrc ? undefined : (monogram ?? initialsOf(name));
171
+ ---
172
+
173
+ <BreadcrumbBand>
174
+ <slot name="breadcrumb" />
175
+ </BreadcrumbBand>
176
+
177
+ <main class="pb-12 lg:pb-20" data-person-page>
178
+ <div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
179
+ <ContentHero
180
+ title={name}
181
+ theme="warm"
182
+ overlay="editorial"
183
+ minHeight="short"
184
+ image={image}
185
+ imageFallback={fallbackSrc}
186
+ imageAlt={imageAlt ?? name}
187
+ monogram={heroMonogram}
188
+ >
189
+ <Fragment slot="badges">
190
+ {roles.map((role) => (
191
+ <RoleBadge onImage>{role}</RoleBadge>
192
+ ))}
193
+ <EraChip era={era} basePath={eraBasePath} onImage />
194
+ </Fragment>
195
+ <Fragment slot="subtitle">
196
+ {lifespanText && (
197
+ <p class="font-display text-lg tabular-nums text-overlay-foreground/90 sm:text-xl" data-person-dates>{lifespanText}</p>
198
+ )}
199
+ </Fragment>
200
+ </ContentHero>
201
+
202
+ <Container size="measure" class="pt-8 lg:pt-10">
203
+ <QuickFacts variant="strip" items={facts} class="mb-8" pagefindIgnore={false} />
204
+
205
+ <slot name="before-body" />
206
+
207
+ {showToc && tocItems.length > 1 && (
208
+ <TableOfContents
209
+ class="mb-8"
210
+ showHeader={false}
211
+ items={tocItems}
212
+ sectionSelector="[data-person-section]"
213
+ hideMiniPastLastSection
214
+ />
215
+ )}
216
+
217
+ <section id="person-biography" class="scroll-mt-28" aria-label={toc.biography} data-person-section="biography">
218
+ <div
219
+ class={`prose ${proseClass} max-w-[65ch] [&>p:first-child]:text-[1.2em] [&>p:first-child]:leading-relaxed`}
220
+ data-person-bio
221
+ set:html={bioHtml}
222
+ />
223
+ </section>
224
+
225
+ <slot name="after-body" />
226
+
227
+ {fullStory && !hasFullStoryCard && (
228
+ <p class="mt-6">
229
+ <LinkArrow href={fullStory.href} size="base">{fullStory.label ?? LABELS.personPage.readFullStory}</LinkArrow>
230
+ </p>
231
+ )}
232
+
233
+ {fullStory && hasFullStoryCard && (
234
+ <PersonSection section="full-story" heading={fullStoryHeading} class="mt-12">
235
+ <div class="max-w-md">
236
+ <EntityCard
237
+ href={fullStory.href}
238
+ variant="poster"
239
+ image={fullStory.image}
240
+ fallbackSrc={fullStory.fallbackSrc}
241
+ imageAlt={fullStory.imageAlt ?? fullStory.title ?? ''}
242
+ footerJustify="end"
243
+ >
244
+ <CardPlaceholder slot="fallback"><Icon name="book-open" class="size-10 text-primary/70" /></CardPlaceholder>
245
+ <h3 class="text-xl font-display font-semibold text-foreground">{fullStory.title}</h3>
246
+ {fullStory.summary && <p class="mt-2 text-sm leading-relaxed text-muted-foreground">{fullStory.summary}</p>}
247
+ <ArrowLabel slot="footer-cta" class="text-sm font-medium text-primary">{fullStory.label ?? LABELS.personPage.readFullStory}</ArrowLabel>
248
+ </EntityCard>
249
+ </div>
250
+ </PersonSection>
251
+ )}
252
+
253
+ {places.length > 0 && (
254
+ <PersonSection section="places" heading={placesHeading} class="mt-12">
255
+ <RelatedContentGrid items={places} />
256
+ </PersonSection>
257
+ )}
258
+ </Container>
259
+ </div>
260
+
261
+ {timeline && hasTimeline && (
262
+ <Container size="content" class="mt-16">
263
+ <PersonSection section="timeline" heading={timelineHeading} headingClass="mb-8">
264
+ <ChronologyTimeline eras={timeline.eras} items={timeline.items} index={false} />
265
+ </PersonSection>
266
+ </Container>
267
+ )}
268
+
269
+ {readMoreItems.length > 0 && (
270
+ <div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
271
+ <Container size="measure" class="mt-16">
272
+ <PersonSection section="read-more" heading={readMoreHeading}>
273
+ <RelatedContentGrid items={readMoreItems} />
274
+ </PersonSection>
275
+ </Container>
276
+ </div>
277
+ )}
278
+
279
+ <slot name="tail" />
280
+ </main>
@@ -0,0 +1,21 @@
1
+ ---
2
+ // A titled section of the person page: one shell for the full story, places, timeline and read-more blocks.
3
+ interface Props {
4
+ section: string;
5
+ heading: string;
6
+ headingClass?: string;
7
+ class?: string;
8
+ }
9
+
10
+ const { section, heading, headingClass = 'mb-6', class: className } = Astro.props;
11
+ ---
12
+
13
+ <section
14
+ id={`person-${section}`}
15
+ class:list={['scroll-mt-28', className]}
16
+ aria-labelledby={`person-${section}-heading`}
17
+ data-person-section={section}
18
+ >
19
+ <h2 id={`person-${section}-heading`} class:list={['font-display text-2xl font-semibold text-foreground', headingClass]}>{heading}</h2>
20
+ <slot />
21
+ </section>
@@ -0,0 +1,12 @@
1
+ ---
2
+ // The outlined label for a role or an area of expertise (person and author cards, person page).
3
+ import Badge from '../primitives/Badge.astro';
4
+
5
+ interface Props {
6
+ /** Opaque scrim tone for a badge laid over a photo or the hero overlay. */
7
+ onImage?: boolean;
8
+ }
9
+ const { onImage = false } = Astro.props;
10
+ ---
11
+
12
+ <Badge tone={onImage ? 'onImage' : 'outline'} size="md" lines={0}><slot /></Badge>
@@ -22,10 +22,14 @@ export { default as InsiderTips } from './InsiderTips.astro';
22
22
  export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
23
23
  export { default as AuthorStrip } from './AuthorStrip.astro';
24
24
  export { default as EraChip } from './EraChip.astro';
25
+ export { default as PersonChip } from './PersonChip.astro';
26
+ export { default as PersonCard } from './PersonCard.astro';
27
+ export { default as PersonPage } from './PersonPage.astro';
25
28
  export { default as AlsoExplore } from './AlsoExplore.astro';
26
29
  export { default as VisitTiming } from './VisitTiming.astro';
27
30
  export { default as ChronologyTimeline } from './ChronologyTimeline.astro';
28
31
  export { groupByEra, type TimelineItem } from './chronology';
32
+ export { monogram } from './monogram';
29
33
  export type { AlsoExploreItem } from './AlsoExplore.astro';
30
34
  export type { VisitTimingData } from './VisitTiming.astro';
31
35
  export { default as AnswerBox } from './interludes/AnswerBox.astro';
@@ -0,0 +1,21 @@
1
+ const PARTICLES = new Set([
2
+ 'al', 'el', 'ibn', 'ben', 'bin', 'bint', 'abu', 'd', 'de', 'del', 'della', 'di', 'da', 'do', 'dos', 'das', 'du', 'des',
3
+ 'van', 'von', 'der', 'den', 'la', 'le', 'los', 'las', 'y', 'e', 'of', 'the',
4
+ ]);
5
+
6
+ /**
7
+ * Initials for a name tile: first and last significant word, at most two letters. Particles (al, ibn, de, van...),
8
+ * diacritics, apostrophes and Roman numeral suffixes are ignored; one significant word gives one letter.
9
+ */
10
+ export function monogram(name: string): string {
11
+ const words = name
12
+ .normalize('NFD')
13
+ .replace(/[̀-ͯ]/g, '')
14
+ .split(/[\s\-'’‘ʻʿʾ]+/)
15
+ .map((w) => w.replace(/[^\p{L}]/gu, ''))
16
+ .filter(Boolean);
17
+ const kept = words.filter((w, i) => !PARTICLES.has(w.toLowerCase()) && !(i > 0 && /^[IVXLC]+$/.test(w)));
18
+ const pick = kept.length ? kept : words.slice(0, 1);
19
+ const letters = pick.length > 1 ? [pick[0], pick[pick.length - 1]] : pick;
20
+ return letters.map((w) => w[0].toUpperCase()).join('');
21
+ }
@@ -73,6 +73,21 @@ export const LABELS = {
73
73
  articleCategoryNav: { ariaLabel: 'Browse articles by topic', allLabel: 'All' },
74
74
  articleFeatureCard: { readLabel: 'Read article', updatedLabel: 'Updated' },
75
75
  eraChip: { ariaLabel: (label: string, range: string) => `${label}, ${range}: see this era on the chronology` },
76
+ personChip: { ariaLabel: (name: string, dates?: string) => `${name}${dates ? `, ${dates}` : ''}: read their profile` },
77
+ personCard: { cta: 'Read the profile' },
78
+ personPage: {
79
+ readFullStory: 'Read the full story',
80
+ fullStoryHeading: 'The full story',
81
+ placesHeading: 'Places to see',
82
+ readMoreHeading: 'Read more',
83
+ timelineHeading: 'In their time',
84
+ alsoKnownAs: 'Also known as',
85
+ lifespan: (years: string, circa?: boolean, age?: number) =>
86
+ `Lived ${circa ? 'c. ' : ''}${years}${age ? ` (about ${age} years)` : ''}`,
87
+ born: (year: string, circa?: boolean) => `Born ${circa ? 'c. ' : ''}${year}`,
88
+ died: (year: string, circa?: boolean) => `Died ${circa ? 'c. ' : ''}${year}`,
89
+ tocLabels: { biography: 'Biography', places: 'Places', timeline: 'Timeline', readMore: 'Read more' },
90
+ },
76
91
  articleTopicPager: {
77
92
  heading: (topic: string) => `Next in ${topic}`,
78
93
  newerLabel: 'Newer',
@@ -18,6 +18,8 @@ interface Props {
18
18
  imageAlt?: string;
19
19
  /** HTML title attribute of the hero image (tooltip / image SEO). */
20
20
  imageTitle?: string;
21
+ /** Initials drawn on a dark panel when the hero has no image of any kind. */
22
+ monogram?: string;
21
23
  /** Unknown names fall back to `dark` (`stone` and `nature` are such names on Granada). */
22
24
  theme?: string;
23
25
  /** Per-theme gradient classes layered over the built-in map, for a site whose theme looks differ. */
@@ -35,6 +37,7 @@ const {
35
37
  imagePlaceholder,
36
38
  imageAlt = title,
37
39
  imageTitle,
40
+ monogram,
38
41
  theme = 'dark',
39
42
  themeGradients: themeOverrides,
40
43
  overlay = 'themed',
@@ -109,6 +112,12 @@ const [badges, subtitle, info, actions, decoration] = await Promise.all(
109
112
  loading="eager"
110
113
  fetchpriority="high"
111
114
  />
115
+ ) : monogram ? (
116
+ <div class="absolute inset-0 bg-overlay" aria-hidden="true">
117
+ <span class="absolute right-6 bottom-0 font-display text-[11rem] leading-none text-overlay-foreground/10 select-none sm:right-12 sm:text-[16rem] lg:text-[22rem]">
118
+ {monogram}
119
+ </span>
120
+ </div>
112
121
  ) : null}
113
122
  {overlay === 'editorial' ? (
114
123
  <>
@@ -31,7 +31,7 @@ import { PILL_BASE } from './pillRecipe';
31
31
  // Extends the span attributes so call sites can pass id / aria-* / data-* / slot through.
32
32
  interface Props extends Omit<HTMLAttributes<'span'>, 'class'> {
33
33
  /** Preset color. Use `custom` + `toneClass` for runtime category/type color maps. */
34
- tone?: BrandTone | 'neutral' | 'onImage' | 'custom';
34
+ tone?: BrandTone | 'neutral' | 'outline' | 'onImage' | 'custom';
35
35
  /** Color classes for tone="custom" (e.g. typeColors[type]). */
36
36
  toneClass?: string;
37
37
  size?: 'sm' | 'md' | 'lg';
@@ -72,6 +72,7 @@ const tones = {
72
72
  secondary: 'bg-secondary text-secondary-foreground',
73
73
  accent: 'bg-accent-tint text-accent-emphasis',
74
74
  neutral: 'bg-muted text-muted-foreground',
75
+ outline: 'border bg-muted/50 text-foreground border-border',
75
76
  onImage: 'bg-badge-on-image text-badge-on-image-foreground backdrop-blur-sm',
76
77
  custom: '',
77
78
  } as const;