@sarimarcus/content-sites-ui 0.41.3 → 0.42.1

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,18 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.42.1 (2026-10-21)
6
+
7
+ - `composed`: `EntityCard` gains `footerDivider` (default `true`, the hairline above the footer row); every existing caller renders identically. `editorial`: `PersonCard` and the featured full-story card of `PersonPage` pass `false`, so the divider above the CTA is gone on person cards (SLA-3303).
8
+
9
+ ## 0.42.0 (2026-10-21)
10
+
11
+ - `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).
12
+
13
+ ### Breaking
14
+
15
+ - `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.
16
+
5
17
  ## 0.41.3 (2026-10-21)
6
18
 
7
19
  - `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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.41.3",
3
+ "version": "0.42.1",
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.3"
53
+ "@sarimarcus/content-sites-core": "0.42.1"
54
54
  }
55
55
  }
@@ -17,6 +17,7 @@
17
17
  * tone — 'primary' or 'accent': the article border and the focus ring.
18
18
  * footer — false drops the footer row (a card whose CTA ends its body).
19
19
  * footerJustify — 'between' (meta start, CTA end) or 'end' (CTA only).
20
+ * footerDivider — false drops the hairline above the footer row.
20
21
  * Without `image`, `fallbackSrc` renders an <img>; without that too, the `fallback` slot renders (nothing if absent).
21
22
  * Under prefers-reduced-motion the hover zoom is cancelled.
22
23
  *
@@ -48,6 +49,7 @@ interface Props {
48
49
  tone?: 'primary' | 'accent';
49
50
  footer?: boolean;
50
51
  footerJustify?: 'between' | 'end';
52
+ footerDivider?: boolean;
51
53
  class?: string;
52
54
  }
53
55
 
@@ -64,6 +66,7 @@ const {
64
66
  tone = 'primary',
65
67
  footer = true,
66
68
  footerJustify = 'between',
69
+ footerDivider = true,
67
70
  class: className,
68
71
  } = Astro.props;
69
72
 
@@ -81,9 +84,10 @@ const linkClass = poster
81
84
  const mediaClass = `relative ${media} overflow-hidden img-placeholder shrink-0`;
82
85
  const contentClass = poster ? 'p-4 sm:p-5 flex flex-col flex-1' : 'flex-1 p-4 sm:p-5 flex flex-col';
83
86
  const justify = footerJustify === 'end' ? 'justify-end' : 'justify-between';
87
+ const divider = footerDivider ? ' border-t border-border' : '';
84
88
  const footerClass = poster
85
- ? `flex items-center ${justify} pt-3 border-t border-border mt-auto`
86
- : `flex items-center ${justify} pt-3 border-t border-border`;
89
+ ? `flex items-center ${justify} pt-3${divider} mt-auto`
90
+ : `flex items-center ${justify} pt-3${divider}`;
87
91
  // motion-reduce:group-hover:scale-100 has group-hover:scale-105's specificity and compiles after it, so it wins.
88
92
  const imgClass = 'w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 motion-reduce:group-hover:scale-100';
89
93
  const loading = index < eager ? 'eager' : 'lazy';
@@ -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`}>
@@ -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}
@@ -1,13 +1,17 @@
1
1
  ---
2
2
  /**
3
- * PersonCard — a historical figure's teaser card on EntityCard's poster frame: portrait, name, life dates, role
4
- * labels and a clamped description. Every string arrives as a prop (roles are the site's display labels); `ctaLabel`
5
- * falls back to LABELS. Without `image` or `fallbackSrc` the image block stays an empty placeholder.
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.
6
7
  */
7
8
  import type { ImageMetadata } from 'astro';
8
9
  import EntityCard from '../composed/EntityCard.astro';
9
10
  import RoleBadge from './RoleBadge.astro';
10
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';
11
15
  import { LABELS } from '../internal/labels';
12
16
 
13
17
  interface Props {
@@ -19,6 +23,13 @@ interface Props {
19
23
  /** Life dates as displayed, e.g. core `formatLifeDates`. */
20
24
  dates?: string;
21
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. */
22
33
  description?: string;
23
34
  ctaLabel?: string;
24
35
  headingLevel?: 2 | 3;
@@ -36,6 +47,9 @@ const {
36
47
  imageAlt,
37
48
  dates,
38
49
  roles = [],
50
+ knownFor,
51
+ eraLabel,
52
+ monogram,
39
53
  description,
40
54
  ctaLabel = LABELS.personCard.cta,
41
55
  headingLevel = 3,
@@ -46,6 +60,7 @@ const {
46
60
  } = Astro.props;
47
61
 
48
62
  const Heading = `h${headingLevel}` as 'h2' | 'h3';
63
+ const initials = monogram ?? initialsOf(name);
49
64
  ---
50
65
 
51
66
  <EntityCard
@@ -58,17 +73,28 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3';
58
73
  eager={eager}
59
74
  sizes={sizes}
60
75
  footerJustify="end"
76
+ footerDivider={false}
61
77
  class={className}
62
78
  >
79
+ {initials && (
80
+ <CardPlaceholder slot="fallback">
81
+ <span class="font-display text-6xl font-semibold text-primary/70 select-none">{initials}</span>
82
+ </CardPlaceholder>
83
+ )}
63
84
  <Heading class="text-xl font-display font-semibold text-foreground">{name}</Heading>
64
85
  {dates && <p class="mt-1 text-sm tabular-nums text-muted-foreground">{dates}</p>}
65
- {roles.length > 0 && (
86
+ {(eraLabel || roles.length > 0) && (
66
87
  <div class="mt-3 flex flex-wrap gap-2">
88
+ {eraLabel && <Badge tone="primary" size="md" lines={0}><span class="tabular-nums">{eraLabel}</span></Badge>}
67
89
  {roles.map((role) => (
68
90
  <RoleBadge>{role}</RoleBadge>
69
91
  ))}
70
92
  </div>
71
93
  )}
72
- {description && <p class="mt-3 text-sm leading-relaxed text-muted-foreground line-clamp-3">{description}</p>}
94
+ {knownFor ? (
95
+ <p class="mt-3 text-sm leading-relaxed text-foreground">{knownFor}</p>
96
+ ) : description ? (
97
+ <p class="mt-3 text-sm leading-relaxed text-muted-foreground line-clamp-3">{description}</p>
98
+ ) : null}
73
99
  <ArrowLabel slot="footer-cta" class="text-sm font-medium text-primary">{ctaLabel}</ArrowLabel>
74
100
  </EntityCard>
@@ -1,29 +1,43 @@
1
1
  ---
2
2
  /**
3
- * PersonPage — the shell and shared body of a historical figure's page. The route keeps paths, data, SEO and schema
4
- * (core `historicalPersonSchema`), renders markdown to `bioHtml`, and wraps this template in the site Layout. Order:
5
- * portrait and roles, name, life dates with the era chip, the short bio, "Read the full story" to the person's
6
- * primary article, places to see and related reading (`RelatedContentGrid`, not `RelatedContent`: the person page
7
- * owns its headings and `RelatedContent` carries the carousel CSS), then the era on a ChronologyTimeline. Every string
8
- * is a prop or a LABELS default; a block with no data renders nothing. Slots:
9
- * breadcrumb — the site Breadcrumb, in the band under the header.
10
- * before-body — site blocks between the header and the bio.
11
- * after-body — site blocks after the bio and the full-story link.
12
- * tail — full-width blocks after the column.
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.
13
18
  */
14
19
  import type { ImageMetadata } from 'astro';
15
20
  import type { EraDefinition } from '@sarimarcus/content-sites-core/eras';
21
+ import { lifeSpan } from '@sarimarcus/content-sites-core/people';
16
22
  import Container from '../layout/Container.astro';
17
23
  import BreadcrumbBand from '../layout/BreadcrumbBand.astro';
24
+ import ContentHero from '../layout/ContentHero.astro';
18
25
  import RoleBadge from './RoleBadge.astro';
19
26
  import LinkArrow from '../primitives/LinkArrow.astro';
20
- import ResponsivePicture from '../primitives/ResponsivePicture.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';
21
31
  import RelatedContentGrid from '../composed/RelatedContentGrid.astro';
22
32
  import type { RelatedItem } from '../composed/related';
23
33
  import type { TrustedHtml } from '../types';
24
34
  import EraChip from './EraChip.astro';
25
35
  import ChronologyTimeline from './ChronologyTimeline.astro';
26
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';
27
41
  import { LABELS } from '../internal/labels';
28
42
 
29
43
  type PersonImage =
@@ -38,8 +52,21 @@ type PersonImage =
38
52
 
39
53
  type Props = PersonImage & {
40
54
  name: string;
41
- /** Life dates as displayed, e.g. core `formatLifeDates`. */
55
+ /** Life years as displayed, e.g. core `formatLifeDates`; used when no `birthYear`/`deathYear` is given. */
42
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;
43
70
  /** Role labels as displayed. */
44
71
  roles?: readonly string[];
45
72
  era?: EraDefinition;
@@ -47,20 +74,45 @@ type Props = PersonImage & {
47
74
  bioHtml: TrustedHtml;
48
75
  /** Prose modifiers after `prose`. */
49
76
  proseClass?: string;
50
- /** The person's primary article; `label` falls back to LABELS. */
51
- fullStory?: { href: string; label?: 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;
52
91
  places?: readonly RelatedItem[];
53
92
  placesHeading?: string;
93
+ /** Caller-ranked: the first `readMoreLimit` render. */
54
94
  readMore?: readonly RelatedItem[];
95
+ /** Cards shown under read more. */
96
+ readMoreLimit?: number;
55
97
  readMoreHeading?: string;
56
98
  /** The era in context: pass the person's era only, or the whole chronology. */
57
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>>;
58
102
  pagefindType?: string;
59
103
  };
60
104
 
61
105
  const {
62
106
  name,
63
107
  dates,
108
+ birthYear,
109
+ deathYear,
110
+ circa = false,
111
+ lifespan,
112
+ aliases = [],
113
+ aliasesLabel = LABELS.personPage.alsoKnownAs,
114
+ contextFact,
115
+ monogram,
64
116
  roles = [],
65
117
  image,
66
118
  fallbackSrc,
@@ -70,98 +122,160 @@ const {
70
122
  bioHtml,
71
123
  proseClass = 'prose-lg',
72
124
  fullStory,
125
+ fullStoryHeading = LABELS.personPage.fullStoryHeading,
73
126
  places = [],
74
127
  placesHeading = LABELS.personPage.placesHeading,
75
128
  readMore = [],
129
+ readMoreLimit = 4,
76
130
  readMoreHeading = LABELS.personPage.readMoreHeading,
77
131
  timeline,
132
+ tocLabels,
78
133
  pagefindType = 'person',
79
134
  } = Astro.props;
80
135
 
81
136
  const timelineHeading = timeline?.heading ?? LABELS.personPage.timelineHeading;
82
- const hasTimeline = timeline && Object.values(timeline.items).some((list) => list.length > 0);
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));
83
171
  ---
84
172
 
85
173
  <BreadcrumbBand>
86
174
  <slot name="breadcrumb" />
87
175
  </BreadcrumbBand>
88
176
 
89
- <main class="pt-6 pb-12 lg:pt-8 lg:pb-20" data-person-page>
177
+ <main class="pb-12 lg:pb-20" data-person-page>
90
178
  <div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
91
- <Container size="measure">
92
- <header class="flex flex-col gap-6 sm:flex-row sm:items-start">
93
- {(image || fallbackSrc) && (
94
- <div class="w-40 shrink-0 overflow-hidden rounded-xl bg-muted aspect-[4/5] sm:w-48">
95
- {image ? (
96
- <ResponsivePicture
97
- src={image}
98
- alt={imageAlt}
99
- sizes="(min-width: 640px) 192px, 160px"
100
- loading="eager"
101
- fetchpriority="high"
102
- decoding="async"
103
- class="size-full object-cover"
104
- />
105
- ) : (
106
- <img
107
- src={fallbackSrc}
108
- alt={imageAlt}
109
- loading="eager"
110
- fetchpriority="high"
111
- decoding="async"
112
- class="size-full object-cover"
113
- />
114
- )}
115
- </div>
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>
116
198
  )}
117
- <div class="min-w-0">
118
- {roles.length > 0 && (
119
- <div class="mb-3 flex flex-wrap gap-2">
120
- {roles.map((role) => (
121
- <RoleBadge>{role}</RoleBadge>
122
- ))}
123
- </div>
124
- )}
125
- <h1 class="font-display text-4xl leading-tight text-balance text-foreground">{name}</h1>
126
- {(dates || era) && (
127
- <div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
128
- {dates && <p class="font-display text-xl tabular-nums text-secondary" data-person-dates>{dates}</p>}
129
- <EraChip era={era} basePath={eraBasePath} />
130
- </div>
131
- )}
132
- </div>
133
- </header>
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} />
134
204
 
135
205
  <slot name="before-body" />
136
206
 
137
- <div class={`prose ${proseClass} mt-10 max-w-none`} data-person-bio set:html={bioHtml} />
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>
138
224
 
139
- {fullStory && (
225
+ <slot name="after-body" />
226
+
227
+ {fullStory && !hasFullStoryCard && (
140
228
  <p class="mt-6">
141
229
  <LinkArrow href={fullStory.href} size="base">{fullStory.label ?? LABELS.personPage.readFullStory}</LinkArrow>
142
230
  </p>
143
231
  )}
144
232
 
145
- <slot name="after-body" />
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
+ footerDivider={false}
244
+ >
245
+ <CardPlaceholder slot="fallback"><Icon name="book-open" class="size-10 text-primary/70" /></CardPlaceholder>
246
+ <h3 class="text-xl font-display font-semibold text-foreground">{fullStory.title}</h3>
247
+ {fullStory.summary && <p class="mt-2 text-sm leading-relaxed text-muted-foreground">{fullStory.summary}</p>}
248
+ <ArrowLabel slot="footer-cta" class="text-sm font-medium text-primary">{fullStory.label ?? LABELS.personPage.readFullStory}</ArrowLabel>
249
+ </EntityCard>
250
+ </div>
251
+ </PersonSection>
252
+ )}
146
253
 
147
- {[
148
- { id: 'places', heading: placesHeading, items: places },
149
- { id: 'read-more', heading: readMoreHeading, items: readMore },
150
- ].map((group) => group.items.length > 0 && (
151
- <section class="mt-12" aria-labelledby={`person-${group.id}`} data-person-section={group.id}>
152
- <h2 id={`person-${group.id}`} class="mb-6 text-2xl font-display font-semibold text-foreground">{group.heading}</h2>
153
- <RelatedContentGrid items={group.items} />
154
- </section>
155
- ))}
254
+ {places.length > 0 && (
255
+ <PersonSection section="places" heading={placesHeading} class="mt-12">
256
+ <RelatedContentGrid items={places} />
257
+ </PersonSection>
258
+ )}
156
259
  </Container>
157
260
  </div>
158
261
 
159
262
  {timeline && hasTimeline && (
160
263
  <Container size="content" class="mt-16">
161
- <h2 class="mb-8 font-display text-2xl font-semibold text-foreground">{timelineHeading}</h2>
162
- <ChronologyTimeline eras={timeline.eras} items={timeline.items} index={false} />
264
+ <PersonSection section="timeline" heading={timelineHeading} headingClass="mb-8">
265
+ <ChronologyTimeline eras={timeline.eras} items={timeline.items} index={false} />
266
+ </PersonSection>
163
267
  </Container>
164
268
  )}
165
269
 
270
+ {readMoreItems.length > 0 && (
271
+ <div data-pagefind-body data-pagefind-meta={`type:${pagefindType}`}>
272
+ <Container size="measure" class="mt-16">
273
+ <PersonSection section="read-more" heading={readMoreHeading}>
274
+ <RelatedContentGrid items={readMoreItems} />
275
+ </PersonSection>
276
+ </Container>
277
+ </div>
278
+ )}
279
+
166
280
  <slot name="tail" />
167
281
  </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>
@@ -1,6 +1,12 @@
1
1
  ---
2
2
  // The outlined label for a role or an area of expertise (person and author cards, person page).
3
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;
4
10
  ---
5
11
 
6
- <Badge tone="outline" size="md" lines={0}><slot /></Badge>
12
+ <Badge tone={onImage ? 'onImage' : 'outline'} size="md" lines={0}><slot /></Badge>
@@ -29,6 +29,7 @@ export { default as AlsoExplore } from './AlsoExplore.astro';
29
29
  export { default as VisitTiming } from './VisitTiming.astro';
30
30
  export { default as ChronologyTimeline } from './ChronologyTimeline.astro';
31
31
  export { groupByEra, type TimelineItem } from './chronology';
32
+ export { monogram } from './monogram';
32
33
  export type { AlsoExploreItem } from './AlsoExplore.astro';
33
34
  export type { VisitTimingData } from './VisitTiming.astro';
34
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
+ }
@@ -77,9 +77,16 @@ export const LABELS = {
77
77
  personCard: { cta: 'Read the profile' },
78
78
  personPage: {
79
79
  readFullStory: 'Read the full story',
80
+ fullStoryHeading: 'The full story',
80
81
  placesHeading: 'Places to see',
81
82
  readMoreHeading: 'Read more',
82
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' },
83
90
  },
84
91
  articleTopicPager: {
85
92
  heading: (topic: string) => `Next in ${topic}`,
@@ -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
  <>