@sarimarcus/content-sites-ui 0.23.0 → 0.24.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,19 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.24.0 (2026-10-05)
6
+
7
+ - `./primitives`: `ResponsiveImage` and `ResponsivePicture`, the responsive image call site. They take `sizes` and never `widths`, `formats` or `fallbackFormat`: the variant service builds the ladder and serves WebP only (SLA-2960).
8
+ - Every package component with a ladder image uses them. Output is unchanged except that each `<picture>` drops its `<source>`: with WebP the only format it repeated the `<img>` srcset, and for an image without variants (a gallery) it was an empty `srcset`. Proven on all three sites: every changed page equals its 0.23.0 build with those `<source>` elements removed (SLA-2960).
9
+
10
+ ### Deprecated
11
+
12
+ - `EntityCard` and `InlineImage` `formats`: ignored, since images are WebP only. Removed in 0.25.0; `scripts/platform/migrations/responsive-image.mjs` drops it from a site's call sites (SLA-2960).
13
+
14
+ ### Breaking
15
+
16
+ - The image-variants push gate refuses any `widths=` call site once a site pins core 0.24.0. Run `scripts/platform/migrations/responsive-image.mjs --site-root=<site>` in the pin-bump commit; it moves every `<Picture>`/`<Image widths={IMAGE_LADDER}>` onto ui `ResponsivePicture`/`ResponsiveImage` (SLA-2960).
17
+
5
18
  ## 0.23.0 (2026-10-04)
6
19
 
7
20
  - No changes; lockstep release with core (SLA-2958).
package/README.md CHANGED
@@ -12,7 +12,7 @@ text arrives through props and slots.
12
12
  ## Primitives
13
13
 
14
14
  ```ts
15
- import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callout, FeatureList, Blockquote, SectionHeader, PILL_BASE, isBadgeLength, PRICE_LABEL_MAX } from '@sarimarcus/content-sites-ui/primitives';
15
+ import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callout, FeatureList, Blockquote, SectionHeader, ResponsiveImage, ResponsivePicture, PILL_BASE, isBadgeLength, PRICE_LABEL_MAX } from '@sarimarcus/content-sites-ui/primitives';
16
16
  ```
17
17
 
18
18
  - `Icon`: any Lucide icon by kebab-case name.
@@ -42,6 +42,11 @@ import { Icon, Badge, Chip, ArrowLabel, SectionDivider, LinkArrow, Button, Callo
42
42
  - `Blockquote`: a centred pull-quote, `quote` plus an optional `source`.
43
43
  - `SectionHeader`: an icon tile (slot `icon`), an `h2` `title` and an optional `action` slot. `iconBg` and
44
44
  `iconColor` are raw classes the site supplies.
45
+ - `ResponsiveImage`: Astro's `<Image>` at every `IMAGE_LADDER` width the source has variants for. Takes `<Image>`'s
46
+ props less `widths`, `densities` and `format`, and requires `sizes`. A fixed-size image (avatar, thumbnail) stays a
47
+ plain `<Image width={N}>`.
48
+ - `ResponsivePicture`: `ResponsiveImage` inside a `<picture>` that takes `pictureAttributes`, for a styled wrapper.
49
+ Same HTML as Astro's `<Picture>` with WebP, less its redundant `<source>`.
45
50
  - `PILL_BASE`: the shared pill geometry. `isBadgeLength`, `BADGE_MAX_CHARS`, `PRICE_LABEL_MAX`: the label-length
46
51
  limits a call site branches on (pill or prose); a price-range schema `.max()` must equal `PRICE_LABEL_MAX`.
47
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.23.0",
3
+ "version": "0.24.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.23.0"
52
+ "@sarimarcus/content-sites-core": "0.24.0"
53
53
  }
54
54
  }
@@ -1,5 +1,4 @@
1
1
  ---
2
- import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
3
2
  /**
4
3
  * AffiliateCard — a bookable activity as one sponsored link: image with the title over it, description, duration,
5
4
  * price, rating and the CTA. `href` arrives finished (tagged by the site) and `productId` is the site's.
@@ -17,12 +16,12 @@ import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
17
16
  * the default derives a page-unique id).
18
17
  */
19
18
  import type { ImageMetadata } from 'astro';
20
- import { Picture } from 'astro:assets';
21
19
  import { deterministicId } from '@sarimarcus/content-sites-core/dom';
22
20
  import Badge from '../primitives/Badge.astro';
23
21
  import Icon from '../primitives/Icon.astro';
24
22
  import type { AffiliateCardLabels } from './types';
25
23
  import { templateLine } from './templateText';
24
+ import ResponsivePicture from '../primitives/ResponsivePicture.astro';
26
25
 
27
26
  interface Props {
28
27
  href: string;
@@ -168,13 +167,10 @@ const priceFrame = skipQueueFrame && title.toLowerCase().includes('skip')
168
167
  class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
169
168
  />
170
169
  ) : (
171
- <Picture
170
+ <ResponsivePicture
172
171
  src={image}
173
172
  alt={title}
174
- widths={IMAGE_LADDER}
175
173
  sizes={imageSizes}
176
- formats={['webp']}
177
- fallbackFormat="webp"
178
174
  loading="lazy"
179
175
  decoding="async"
180
176
  fetchpriority={imageFetchpriority}
@@ -1,5 +1,4 @@
1
1
  ---
2
- import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
3
2
  /**
4
3
  * EntityCard — the shared frame of an entity teaser card: a whole-card link with a focus ring, an image block
5
4
  * (optimized Picture, or a fallback img), a content area and a footer row. Everything that varies between card types
@@ -15,10 +14,8 @@ import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
15
14
  * footer-meta — start of the footer row (location, closing time…).
16
15
  * footer-cta — end of the footer row.
17
16
  */
18
- import { Picture } from 'astro:assets';
19
17
  import type { ImageMetadata } from 'astro';
20
-
21
- type ImageFormat = 'avif' | 'webp' | 'png' | 'jpeg' | 'jpg' | 'svg';
18
+ import ResponsivePicture from '../primitives/ResponsivePicture.astro';
22
19
 
23
20
  interface Props {
24
21
  href: string;
@@ -27,7 +24,8 @@ interface Props {
27
24
  /** Shown when `image` is absent. */
28
25
  fallbackSrc: string;
29
26
  imageAlt: string;
30
- formats?: ImageFormat[];
27
+ /** @deprecated Ignored: images are WebP only (SLA-2960). Removed in the next minor. */
28
+ formats?: Array<'avif' | 'webp' | 'png' | 'jpeg' | 'jpg' | 'svg'>;
31
29
  /** Position in the list: the first three load eagerly, the first one at high priority. */
32
30
  index?: number;
33
31
  class?: string;
@@ -39,7 +37,6 @@ const {
39
37
  image,
40
38
  fallbackSrc,
41
39
  imageAlt,
42
- formats = ['webp'],
43
40
  index = 999,
44
41
  class: className,
45
42
  } = Astro.props;
@@ -63,13 +60,10 @@ const footerClass = poster
63
60
  <a href={href} class={linkClass}>
64
61
  <div class={mediaClass}>
65
62
  {image ? (
66
- <Picture
63
+ <ResponsivePicture
67
64
  src={image}
68
65
  alt={imageAlt}
69
- widths={IMAGE_LADDER}
70
66
  sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw"
71
- formats={formats}
72
- fallbackFormat="webp"
73
67
  pictureAttributes={{ class: 'block w-full h-full' }}
74
68
  class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
75
69
  loading={index < 3 ? "eager" : "lazy"}
@@ -1,8 +1,7 @@
1
1
  ---
2
- import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
3
- import { Picture } from 'astro:assets';
4
2
  import type { ImageMetadata } from 'astro';
5
3
  import type { TrustedHtml } from '../types/index.ts';
4
+ import ResponsivePicture from '../primitives/ResponsivePicture.astro';
6
5
 
7
6
  interface Props {
8
7
  /** Imported image (ImageMetadata from Astro asset pipeline) */
@@ -43,7 +42,7 @@ interface Props {
43
42
  aspectRatio?: string;
44
43
  /** Optional URL to wrap the image in a link */
45
44
  href?: string;
46
- /** Image formats for the <picture> sources (WebP only: the variant service serves nothing else). */
45
+ /** @deprecated Ignored: images are WebP only (SLA-2960). Removed in the next minor. */
47
46
  formats?: 'webp'[];
48
47
  /** Additional CSS classes on the outer figure element */
49
48
  class?: string;
@@ -61,7 +60,6 @@ const {
61
60
  size = 'medium',
62
61
  aspectRatio = '16/9',
63
62
  href,
64
- formats = ['webp'],
65
63
  class: className,
66
64
  } = Astro.props;
67
65
 
@@ -104,13 +102,10 @@ const sizes = isFloat
104
102
  >
105
103
  {href ? (
106
104
  <a href={href} class="block group" aria-label={captionText || alt}>
107
- <Picture
105
+ <ResponsivePicture
108
106
  src={src}
109
107
  alt={alt}
110
- widths={IMAGE_LADDER}
111
108
  sizes={sizes}
112
- formats={formats}
113
- fallbackFormat="webp"
114
109
  pictureAttributes={{ class: 'block w-full h-full' }}
115
110
  class:list={[
116
111
  'w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]',
@@ -122,13 +117,10 @@ const sizes = isFloat
122
117
  />
123
118
  </a>
124
119
  ) : (
125
- <Picture
120
+ <ResponsivePicture
126
121
  src={src}
127
122
  alt={alt}
128
- widths={IMAGE_LADDER}
129
123
  sizes={sizes}
130
- formats={formats}
131
- fallbackFormat="webp"
132
124
  pictureAttributes={{ class: 'block w-full h-full' }}
133
125
  class:list={[
134
126
  'w-full object-cover',
@@ -1,12 +1,11 @@
1
1
  ---
2
- import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
3
2
  /**
4
3
  * RelatedContentCard — one card inside RelatedContent. The grid and the carousel render this same card; only the
5
4
  * shell classes and the responsive image hints differ. Three shapes, by precedence: image-led, icon-led, plain text.
6
5
  */
7
- import { Picture } from 'astro:assets';
8
6
  import Icon from '../primitives/Icon.astro';
9
7
  import type { RelatedItem, RelatedTone } from './related';
8
+ import ResponsivePicture from '../primitives/ResponsivePicture.astro';
10
9
 
11
10
  interface Props {
12
11
  item: RelatedItem;
@@ -60,12 +59,9 @@ const hasImage = !!item.image;
60
59
  >
61
60
  {hasImage && (
62
61
  <div class="relative aspect-video overflow-hidden">
63
- <Picture
62
+ <ResponsivePicture
64
63
  src={item.image!}
65
64
  alt={item.imageAlt || item.title}
66
- widths={IMAGE_LADDER}
67
- formats={['webp']}
68
- fallbackFormat="webp"
69
65
  sizes={pictureSizes}
70
66
  class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
71
67
  />
@@ -1,13 +1,12 @@
1
1
  ---
2
- import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
3
2
  /**
4
3
  * ArticleCard — one article in a grid: image, title, byline with the published or "Updated" date, description and
5
4
  * a read link. Dates arrive formatted; the image is the route's import.
6
5
  */
7
- import { Picture } from 'astro:assets';
8
6
  import ArticleByline from './ArticleByline.astro';
9
7
  import Icon from '../primitives/Icon.astro';
10
8
  import type { ArticleSummary } from './types';
9
+ import ResponsivePicture from '../primitives/ResponsivePicture.astro';
11
10
 
12
11
  interface Props {
13
12
  article: ArticleSummary;
@@ -29,13 +28,10 @@ const { article: a, readLabel, updatedLabel, loading = 'lazy', pagefindIgnore =
29
28
  >
30
29
  {a.image ? (
31
30
  <div class="aspect-[4/3] overflow-hidden">
32
- <Picture
31
+ <ResponsivePicture
33
32
  src={a.image}
34
33
  alt={a.imageAlt ?? ''}
35
- widths={IMAGE_LADDER}
36
34
  sizes="(max-width: 640px) 100vw, 33vw"
37
- formats={['webp']}
38
- fallbackFormat="webp"
39
35
  class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
40
36
  loading={loading}
41
37
  />
@@ -1,13 +1,12 @@
1
1
  ---
2
- import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
3
2
  /**
4
3
  * ArticleFeatureCard — the wide lead card above an article grid: image left, label, title, byline with the
5
4
  * published or "Updated" date, description and a read link. Dates arrive formatted; the image is the route's import.
6
5
  */
7
- import { Picture } from 'astro:assets';
8
6
  import type { ImageMetadata } from 'astro';
9
7
  import ArticleByline from './ArticleByline.astro';
10
8
  import Icon from '../primitives/Icon.astro';
9
+ import ResponsivePicture from '../primitives/ResponsivePicture.astro';
11
10
 
12
11
  interface Props {
13
12
  href: string;
@@ -41,13 +40,10 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3';
41
40
  <div class="grid sm:grid-cols-[1.2fr_1fr] gap-0">
42
41
  {image ? (
43
42
  <div class="aspect-[4/3] sm:aspect-auto overflow-hidden">
44
- <Picture
43
+ <ResponsivePicture
45
44
  src={image}
46
45
  alt={imageAlt}
47
- widths={IMAGE_LADDER}
48
46
  sizes="(max-width: 640px) 100vw, 50vw"
49
- formats={['webp']}
50
- fallbackFormat="webp"
51
47
  class="w-full h-full object-cover motion-safe:transition-transform motion-safe:duration-500 motion-safe:group-hover:scale-105"
52
48
  loading={loading}
53
49
  fetchpriority={loading === 'eager' ? 'high' : undefined}
@@ -1,12 +1,11 @@
1
1
  ---
2
- import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
3
2
  /**
4
3
  * RelatedCard — an in-article link to another page. `inline`: a ruled "read next" row. `featured`: the mid-article
5
4
  * feature card, image left. The site resolves href, eyebrow and image; an unresolved target should render nothing.
6
5
  */
7
6
  import type { ImageMetadata } from 'astro';
8
- import { Picture } from 'astro:assets';
9
7
  import Icon from '../../primitives/Icon.astro';
8
+ import ResponsivePicture from '../../primitives/ResponsivePicture.astro';
10
9
 
11
10
  interface Props {
12
11
  href: string;
@@ -51,13 +50,10 @@ const { href, eyebrow, title, description, variant = 'inline', image, imageAlt,
51
50
  >
52
51
  <div class="sm:w-2/5 shrink-0 overflow-hidden bg-muted aspect-video sm:aspect-auto">
53
52
  {image ? (
54
- <Picture
53
+ <ResponsivePicture
55
54
  src={image}
56
55
  alt={imageAlt ?? title}
57
- widths={IMAGE_LADDER}
58
56
  sizes="(min-width: 640px) 40vw, 100vw"
59
- formats={['webp']}
60
- fallbackFormat="webp"
61
57
  class="w-full h-full object-cover"
62
58
  loading="lazy"
63
59
  />
@@ -0,0 +1,14 @@
1
+ ---
2
+ /**
3
+ * ResponsiveImage — a content image served at every IMAGE_LADDER width the source has variants for. The variant
4
+ * service builds the ladder and serves WebP only, so a call site passes `sizes` and never widths or formats.
5
+ * A fixed-size image (an avatar, a thumbnail) stays a plain `<Image width={N}>`: without `sizes` a srcset
6
+ * reads as 100vw.
7
+ */
8
+ import { Image } from 'astro:assets';
9
+ import type { ComponentProps } from 'astro/types';
10
+ import { IMAGE_LADDER } from '@sarimarcus/content-sites-core/images';
11
+
12
+ type Props = Omit<ComponentProps<typeof Image>, 'widths' | 'densities' | 'format' | 'sizes'> & { sizes: string };
13
+ ---
14
+ <Image {...Astro.props} widths={IMAGE_LADDER} />
@@ -0,0 +1,22 @@
1
+ ---
2
+ /**
3
+ * ResponsivePicture — ResponsiveImage inside a `<picture>`, for call sites that style or select the wrapper. It
4
+ * renders what Astro's `<Picture>` does minus its `<source>`: with WebP the only format, the source repeated the
5
+ * img's srcset.
6
+ */
7
+ import type { ComponentProps, HTMLAttributes } from 'astro/types';
8
+ import ResponsiveImage from './ResponsiveImage.astro';
9
+
10
+ type Props = ComponentProps<typeof ResponsiveImage> & { pictureAttributes?: HTMLAttributes<'picture'> };
11
+
12
+ const { pictureAttributes = {}, ...props } = Astro.props;
13
+ // Forwarded to <picture> as Astro's <Picture> does, so a parent's scoped styles still reach the wrapper.
14
+ const attrs: Record<string, unknown> = { ...pictureAttributes };
15
+ const scoped = typeof props.class === 'string' ? props.class.match(/\bastro-\w{8}\b/)?.[0] : undefined;
16
+ if (scoped) attrs.class = attrs.class ? `${attrs.class} ${scoped}` : scoped;
17
+ for (const key in props) if (key.startsWith('data-astro-cid')) attrs[key] = (props as Record<string, unknown>)[key];
18
+ // No whitespace before <ResponsiveImage>: Astro's <Image> emits the space before its <img>, so the output matches
19
+ // <Picture>'s byte for byte, less the <source>.
20
+ ---
21
+ <picture {...attrs}><ResponsiveImage {...props} />
22
+ </picture>
@@ -10,5 +10,7 @@ export { default as Callout } from './Callout.astro';
10
10
  export { default as FeatureList } from './FeatureList.astro';
11
11
  export { default as Blockquote } from './Blockquote.astro';
12
12
  export { default as SectionHeader } from './SectionHeader.astro';
13
+ export { default as ResponsiveImage } from './ResponsiveImage.astro';
14
+ export { default as ResponsivePicture } from './ResponsivePicture.astro';
13
15
  export { PILL_BASE } from './pillRecipe';
14
16
  export { BADGE_MAX_CHARS, PRICE_LABEL_MAX, isBadgeLength } from './badgeText';