@sarimarcus/content-sites-ui 0.30.0 → 0.31.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,21 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.31.0 (2026-10-08)
6
+
7
+ - `./editorial` adds `AlsoExplore` (`AlsoExploreItem`) and `VisitTiming` (`VisitTimingData`; props `title`, `id`, `class` default `mt-12`, `labelClass` default `text-[0.8125rem]`), moved from the sites (SLA-3017).
8
+
9
+ ### Breaking
10
+
11
+ - The sites' `components/ui/AlsoExplore.astro` and `VisitTiming.astro` now live in ui; sites run `rewrite-imports.mjs --delete-local`, then `scripts/platform/migrations/core-markdown.mjs` and `scripts/platform/migrations/promoted-site-files.mjs` (Dark Skies' two `<VisitTiming>` calls get `labelClass="text-sm"`) (SLA-3017).
12
+
13
+
14
+ ## 0.30.1 (2026-10-07)
15
+
16
+ - `./editorial`: `StaticProsePage` takes `proseClass`, the variant beside `prose` (default `prose-stone`) and `NotFoundPage` takes `searchBoxClass` (default: the current search-link classes), so a dark theme can pass its own prose variant and input tokens; default output is unchanged (SLA-2989).
17
+ - `./layout`: `ListingShell` takes `contained` (default `true`) and `pagefindIgnore` (default `true`); `false` drops the Container inside `<main>` for a main of full-bleed bands, and keeps a listing's `data-pagefind-body` bands indexed. `mainClass={null}` renders a class-less `<main>`. Default output is unchanged (SLA-2989).
18
+ - `./search`: with `?focus`, `initSearchPage` re-claims input focus every 300 ms for 3 s, stopping at the first pointer or key press, instead of once at 1.7 s and only from `<body>`; the deferred cookie banner moves focus to its own button, which the single reclaim missed. This reaches every site (SLA-2989).
19
+
5
20
  ## 0.30.0 (2026-10-07)
6
21
 
7
22
  - `./affiliate`: `HotelAffiliateCTA` and `AffiliateComparisonTable` links carry `data-track-affiliate-provider` and `data-track-affiliate-placement` so booking clicks are attributed; both take `provider`, `ComparisonRow` takes a per-row one, and without either the provider is read from the link's host (Booking.com links only; any other host is left unattributed) (SLA-2985).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.30.0",
3
+ "version": "0.31.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.30.0"
52
+ "@sarimarcus/content-sites-core": "0.31.0"
53
53
  }
54
54
  }
@@ -0,0 +1,37 @@
1
+ ---
2
+ /** "Also explore": up to four sibling links under a hairline, at the foot of a hub or listing page. */
3
+ import Icon from '../primitives/Icon.astro';
4
+
5
+ export interface AlsoExploreItem {
6
+ href: string;
7
+ label: string;
8
+ }
9
+
10
+ interface Props {
11
+ eyebrow?: string;
12
+ items: AlsoExploreItem[];
13
+ class?: string;
14
+ }
15
+
16
+ const { eyebrow = 'Also explore', items, class: className } = Astro.props;
17
+
18
+ if (import.meta.env.DEV && items.length > 4) {
19
+ console.warn(`AlsoExplore: received ${items.length} items, capping at 4.`);
20
+ }
21
+
22
+ const displayItems = items.slice(0, 4);
23
+ ---
24
+
25
+ {displayItems.length > 0 && (
26
+ <nav class:list={['mt-12 pt-8 border-t border-border', className]} aria-label={eyebrow}>
27
+ <p class="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">{eyebrow}</p>
28
+ <div class="flex flex-wrap gap-x-6 gap-y-2">
29
+ {displayItems.map((item) => (
30
+ <a href={item.href} class="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary transition-colors">
31
+ {item.label}
32
+ <Icon name="arrow-right" class="w-3 h-3" />
33
+ </a>
34
+ ))}
35
+ </div>
36
+ </nav>
37
+ )}
@@ -20,9 +20,11 @@ interface Props {
20
20
  /** Accessible name of the section links. */
21
21
  exploreAriaLabel?: string;
22
22
  links: { href: string; label: string }[];
23
+ /** Classes of the search link; a dark theme passes its own border and background tokens. */
24
+ searchBoxClass?: string;
23
25
  }
24
26
 
25
- const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links } = Astro.props;
27
+ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links, searchBoxClass = 'group flex items-center gap-3 w-full max-w-sm mx-auto px-4 py-3 rounded-xl border border-foreground/20 bg-white/60 backdrop-blur-sm text-left hover:border-primary/40 hover:bg-white/80 transition-all no-underline' } = Astro.props;
26
28
  ---
27
29
 
28
30
  <main class="relative min-h-[80vh] flex items-center justify-center overflow-hidden bg-muted">
@@ -74,7 +76,7 @@ const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaL
74
76
  <a
75
77
  href={searchHref}
76
78
  data-search-trigger
77
- class="group flex items-center gap-3 w-full max-w-sm mx-auto px-4 py-3 rounded-xl border border-foreground/20 bg-white/60 backdrop-blur-sm text-left hover:border-primary/40 hover:bg-white/80 transition-all no-underline"
79
+ class={searchBoxClass}
78
80
  aria-label={searchAriaLabel}
79
81
  >
80
82
  <Icon name="search" class="w-4 h-4 text-muted-foreground shrink-0" />
@@ -18,9 +18,11 @@ interface Props {
18
18
  eyebrow?: string;
19
19
  /** Closing box under the prose: a short line and secondary buttons. */
20
20
  cta?: { text: string; links: CtaLink[] };
21
+ /** Prose variant beside the base `prose` class; a dark theme passes its own. */
22
+ proseClass?: string;
21
23
  }
22
24
 
23
- const { heading, eyebrow, cta } = Astro.props;
25
+ const { heading, eyebrow, cta, proseClass = 'prose-stone' } = Astro.props;
24
26
  ---
25
27
 
26
28
  <main class="py-16 lg:py-24">
@@ -28,7 +30,7 @@ const { heading, eyebrow, cta } = Astro.props;
28
30
  {eyebrow && <p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>}
29
31
  <h1 class="text-3xl sm:text-4xl font-medium font-display mb-8">{heading}</h1>
30
32
 
31
- <div class="prose prose-stone max-w-none">
33
+ <div class:list={['prose', proseClass, 'max-w-none']}>
32
34
  <slot />
33
35
  </div>
34
36
 
@@ -0,0 +1,73 @@
1
+ ---
2
+ /**
3
+ * "When to Visit": best season, quietest hours and visit duration, each shown only when set. `class` replaces the
4
+ * default top margin (`mt-12`); `labelClass` sets the slot label size.
5
+ */
6
+ import Icon from '../primitives/Icon.astro';
7
+
8
+ export interface VisitTimingData {
9
+ bestSeason?: string;
10
+ quietestHours?: string;
11
+ visitDuration?: string;
12
+ }
13
+
14
+ interface Props {
15
+ visitTiming?: VisitTimingData;
16
+ /** Heading text — override to avoid colliding with nearby "When to…" headings. */
17
+ title?: string;
18
+ /** Section id — override when a prose section on the page could slugify to the same value. */
19
+ id?: string;
20
+ class?: string;
21
+ labelClass?: string;
22
+ }
23
+
24
+ const {
25
+ visitTiming,
26
+ title = 'When to Visit',
27
+ id = 'when-to-visit',
28
+ class: className = 'mt-12',
29
+ labelClass = 'text-[0.8125rem]',
30
+ } = Astro.props;
31
+
32
+ const slots = visitTiming
33
+ ? [
34
+ visitTiming.bestSeason
35
+ ? { icon: 'calendar', label: 'Best season', value: visitTiming.bestSeason }
36
+ : null,
37
+ visitTiming.quietestHours
38
+ ? { icon: 'clock', label: 'Quietest hours', value: visitTiming.quietestHours }
39
+ : null,
40
+ visitTiming.visitDuration
41
+ ? { icon: 'hourglass', label: 'Visit duration', value: visitTiming.visitDuration }
42
+ : null,
43
+ ].filter((s): s is { icon: string; label: string; value: string } => s !== null)
44
+ : [];
45
+ ---
46
+
47
+ {slots.length > 0 && (
48
+ <section
49
+ id={id}
50
+ class:list={['scroll-mt-28', className]}
51
+ data-guide-section
52
+ >
53
+ <h2 class="text-2xl font-display font-semibold text-foreground mb-6 leading-tight">{title}</h2>
54
+ <div
55
+ class:list={[
56
+ 'bg-primary/5 border border-primary/15 rounded-xl p-6 grid gap-6',
57
+ slots.length === 1 && 'grid-cols-1',
58
+ slots.length === 2 && 'grid-cols-1 sm:grid-cols-2',
59
+ slots.length === 3 && 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
60
+ ]}
61
+ >
62
+ {slots.map((slot) => (
63
+ <div class="min-w-0">
64
+ <p class:list={['flex items-center gap-1.5', labelClass, 'uppercase tracking-wider text-muted-foreground mb-1.5 leading-none']}>
65
+ <Icon name={slot.icon} class="w-3.5 h-3.5 text-primary/60 shrink-0" />
66
+ {slot.label}
67
+ </p>
68
+ <p class="text-base font-medium text-foreground leading-snug tabular-nums">{slot.value}</p>
69
+ </div>
70
+ ))}
71
+ </div>
72
+ </section>
73
+ )}
@@ -20,6 +20,10 @@ export type { AuthorSummary, ArticleSummary, KeyFact, SourceItem, ArticleCategor
20
20
  export { default as InsiderTips } from './InsiderTips.astro';
21
21
  export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
22
22
  export { default as AuthorStrip } from './AuthorStrip.astro';
23
+ export { default as AlsoExplore } from './AlsoExplore.astro';
24
+ export { default as VisitTiming } from './VisitTiming.astro';
25
+ export type { AlsoExploreItem } from './AlsoExplore.astro';
26
+ export type { VisitTimingData } from './VisitTiming.astro';
23
27
  export { default as AnswerBox } from './interludes/AnswerBox.astro';
24
28
  export { default as ComparisonTable } from './interludes/ComparisonTable.astro';
25
29
  export { default as EvidenceContext } from './interludes/EvidenceContext.astro';
@@ -1,20 +1,26 @@
1
1
  ---
2
2
  /**
3
3
  * ListingShell — the frame of a listing hub: breadcrumb band, hero, optional full-width bands, <main> with a
4
- * Container, and trailing bands, all inside one Pagefind-ignore wrapper (listings are not indexed). The route keeps
4
+ * Container, and trailing bands, all inside one wrapper that is Pagefind-ignored by default (most listings are not
5
+ * indexed; `pagefindIgnore={false}` keeps a listing's own `data-pagefind-body` bands). The route keeps
5
6
  * Layout and passes StructuredData to Layout directly, so its JSON-LD lands in <head>; everything else comes in by slot.
6
7
  */
7
8
  import BreadcrumbBand from './BreadcrumbBand.astro';
8
9
  import Container from './Container.astro';
9
10
 
10
11
  interface Props {
11
- mainClass?: string;
12
+ /** `null` renders a class-less <main>. */
13
+ mainClass?: string | null;
14
+ /** `false` drops the Container inside <main>, for a main made of full-bleed bands that carry their own. */
15
+ contained?: boolean;
16
+ /** `false` keeps the page indexable, for a listing whose bands carry `data-pagefind-body` prose. */
17
+ pagefindIgnore?: boolean;
12
18
  }
13
19
 
14
- const { mainClass = 'py-12 lg:py-20' } = Astro.props;
20
+ const { mainClass = 'py-12 lg:py-20', contained = true, pagefindIgnore = true } = Astro.props;
15
21
  ---
16
22
 
17
- <div data-pagefind-ignore>
23
+ <div data-pagefind-ignore={pagefindIgnore ? '' : undefined}>
18
24
  <BreadcrumbBand ignore={false}>
19
25
  <slot name="breadcrumb" />
20
26
  </BreadcrumbBand>
@@ -23,9 +29,13 @@ const { mainClass = 'py-12 lg:py-20' } = Astro.props;
23
29
  <slot name="before-main" />
24
30
 
25
31
  <main class={mainClass}>
26
- <Container>
32
+ {contained ? (
33
+ <Container>
34
+ <slot />
35
+ </Container>
36
+ ) : (
27
37
  <slot />
28
- </Container>
38
+ )}
29
39
  </main>
30
40
 
31
41
  <slot name="after-main" />
@@ -591,12 +591,22 @@ export function initSearchPage<T extends string>(config: SearchPageConfig<T>): v
591
591
  }
592
592
  };
593
593
  requestAnimationFrame(claimFocus);
594
- // Cookie consent banner may steal focus ~1.5s after load (deferred init).
595
- // Reclaim once if the user hasn't moved focus elsewhere.
594
+ // The deferred cookie banner can steal focus (to its own button, not <body>) up to ~2s after load. Re-claim it
595
+ // every 300 ms for 3 s, and stop as soon as the user interacts.
596
596
  if (initial.focus) {
597
- window.setTimeout(() => {
598
- if (document.activeElement === document.body) claimFocus();
599
- }, 1700);
597
+ let tries = 0;
598
+ const stop = () => {
599
+ window.clearInterval(reclaim);
600
+ document.removeEventListener('pointerdown', stop, true);
601
+ document.removeEventListener('keydown', stop, true);
602
+ };
603
+ const reclaim = window.setInterval(() => {
604
+ tries += 1;
605
+ if (tries > 10) stop();
606
+ else claimFocus();
607
+ }, 300);
608
+ document.addEventListener('pointerdown', stop, true);
609
+ document.addEventListener('keydown', stop, true);
600
610
  }
601
611
  }
602
612
  }