@contentrain/astro-kit 0.39.0 → 0.40.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/README.md CHANGED
@@ -25,7 +25,7 @@ They read its design tokens and pass its gates: `astro check` with the
25
25
  | `tabs` | Tabbed panels; readable as headed sections without JavaScript | Zag.js |
26
26
  | `testimonial` | Quotes with author and role | none |
27
27
  | `gallery` | Image grid with a popover lightbox | none |
28
- | `slider` | Carousel on a scroll-snap row: image, video (played only on request) or text-card slides | Embla |
28
+ | `slider` | Carousel on a scroll-snap row: image, video (a file played only on request, or a YouTube/Vimeo link loaded only on click) or text-card slides | Embla |
29
29
  | `contact-form` | Contentrain Studio form in a section | Studio runtime |
30
30
  | `newsletter` | Sign-up posting straight to the list provider (Mailchimp, Kit, MailerLite, Brevo, Buttondown) | none |
31
31
  | `embed` | Video, map or player loaded on click; a provider link until then (YouTube via nocookie) | a few lines, no dependency |
@@ -33,7 +33,7 @@ They read its design tokens and pass its gates: `astro check` with the
33
33
  | `breadcrumb` | Ancestors, then the current page, optional JSON-LD | none |
34
34
  | `band` | The styled fallback section: heading, lead, image, a grid of items (figure, title, detail, text, points, link) and buttons, in the measured frame | none |
35
35
  | `dialog` | A popup the visitor opens: a button and a modal `<dialog>` with text, buttons and a `form` slot; never opens by itself | a few lines, no dependency |
36
- | `behavior` | A behavior island: a source widget no component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links, image lightbox), kept as its own markup and wired by one vanilla module; static and readable without it | `_shared/behaviors.ts`, no dependency |
36
+ | `behavior` | A behavior island: a source widget no component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links, image lightbox, a video player kept as a poster link that plays in place on click), kept as its own markup and wired by one vanilla module; static and readable without it | `_shared/behaviors.ts`, no dependency |
37
37
 
38
38
  Each component lives in `components/<id>/`:
39
39
 
package/catalog.json CHANGED
@@ -455,6 +455,8 @@
455
455
  "shared": [
456
456
  "Section.astro",
457
457
  "behaviors.ts",
458
+ "embed-frame.ts",
459
+ "embed-target.ts",
458
460
  "measured.ts",
459
461
  "safe-url.ts",
460
462
  "types.ts"
@@ -2226,6 +2228,8 @@
2226
2228
  "uses": [],
2227
2229
  "shared": [
2228
2230
  "KitImage.astro",
2231
+ "embed-frame.ts",
2232
+ "embed-target.ts",
2229
2233
  "safe-url.ts",
2230
2234
  "types.ts"
2231
2235
  ],
@@ -2323,8 +2327,7 @@
2323
2327
  },
2324
2328
  "a11y": "The poster is one link named '<action>: <title>' that opens the provider's page without script. With script, Enter or a click puts the iframe in its place with the title as its accessible name and moves focus into it. Modified clicks (new tab) keep the link behaviour. The play icon is decorative.",
2325
2329
  "files": [
2326
- "Embed.astro",
2327
- "embed.ts"
2330
+ "Embed.astro"
2328
2331
  ]
2329
2332
  },
2330
2333
  {
@@ -5733,7 +5736,7 @@
5733
5736
  {
5734
5737
  "id": "slider",
5735
5738
  "name": "Slider",
5736
- "description": "A carousel of images, videos or text cards, with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
5739
+ "description": "A carousel of images, videos (a file, or a YouTube/Vimeo link loaded only on click) or text cards, with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
5737
5740
  "category": "section",
5738
5741
  "uses": [],
5739
5742
  "shared": [
@@ -5741,6 +5744,8 @@
5741
5744
  "KitImage.astro",
5742
5745
  "Section.astro",
5743
5746
  "SectionHeader.astro",
5747
+ "embed-frame.ts",
5748
+ "embed-target.ts",
5744
5749
  "image.ts",
5745
5750
  "measured.ts",
5746
5751
  "safe-url.ts",
@@ -5797,6 +5802,11 @@
5797
5802
  "label": "Video",
5798
5803
  "description": "A video file (mp4, webm) shown in place of the image, which is its poster. It plays only when the reader starts it."
5799
5804
  },
5805
+ "embed": {
5806
+ "type": "url",
5807
+ "label": "Video link",
5808
+ "description": "A video on another site: its YouTube, Vimeo, Loom or Wistia page (or youtube:<id> / vimeo:<id>). Shown as the image with a play button; nothing from that site loads until the reader clicks it. Wins over Video."
5809
+ },
5800
5810
  "title": {
5801
5811
  "type": "string",
5802
5812
  "label": "Title",
@@ -5819,7 +5829,7 @@
5819
5829
  }
5820
5830
  }
5821
5831
  },
5822
- "description": "Slides: the image's address and text, an optional video (the image is its poster), and an optional link (ctaLabel + ctaHref). A slide with no image or video is a text card; one with none of them is not drawn."
5832
+ "description": "Slides: the image's address and text, an optional video file or video link (the image is its poster), and an optional link (ctaLabel + ctaHref). A slide with no image or video is a text card; one with none of them is not drawn."
5823
5833
  },
5824
5834
  "label": {
5825
5835
  "type": "string",
@@ -5847,6 +5857,11 @@
5847
5857
  "content": false,
5848
5858
  "description": "ui-strings slider.of."
5849
5859
  },
5860
+ "playLabel": {
5861
+ "type": "string",
5862
+ "content": false,
5863
+ "description": "ui-strings embed.play."
5864
+ },
5850
5865
  "loop": {
5851
5866
  "type": "boolean",
5852
5867
  "default": false,
@@ -6055,7 +6070,7 @@
6055
6070
  "classic/owl"
6056
6071
  ]
6057
6072
  },
6058
- "a11y": "WAI-ARIA carousel pattern: a labelled region with aria-roledescription=carousel, slides as labelled groups ('Slide 2 of 5'), labelled previous/next buttons disabled at the ends, and dot buttons marking the current slide. The track and the buttons are tied by aria-controls, the row takes focus (arrow keys page it, polite live announcement). There is no autoplay, so nothing moves unasked: a video slide loads nothing until the reader plays it, never starts by itself, keeps its native controls with its caption under them, and pauses when its slide is paged away; with prefers-reduced-motion slides change without animation. Without JavaScript every slide is reachable by scrolling.",
6073
+ "a11y": "WAI-ARIA carousel pattern: a labelled region with aria-roledescription=carousel, slides as labelled groups ('Slide 2 of 5'), labelled previous/next buttons disabled at the ends, and dot buttons marking the current slide. The track and the buttons are tied by aria-controls, the row takes focus (arrow keys page it, polite live announcement). There is no autoplay, so nothing moves unasked: a video slide loads nothing until the reader plays it, never starts by itself, keeps its native controls with its caption under them, and pauses when its slide is paged away; a video link is a labelled poster link ('Play: title') to the video's page that becomes the player only on a click (no request to the provider before it; YouTube from youtube-nocookie.com, Vimeo with dnt), keeps the poster's box so nothing moves, takes focus, and goes back to its poster when its slide is paged away; with prefers-reduced-motion slides change without animation. Without JavaScript every slide is reachable by scrolling.",
6059
6074
  "files": [
6060
6075
  "Slider.astro",
6061
6076
  "SliderTrack.astro"
@@ -6065,6 +6080,7 @@
6065
6080
  "slides[].image": "slides[].image",
6066
6081
  "slides[].imageAlt": "slides[].image_alt",
6067
6082
  "slides[].video": "slides[].video",
6083
+ "slides[].embed": "slides[].embed",
6068
6084
  "slides[].title": "slides[].title",
6069
6085
  "slides[].text": "slides[].text",
6070
6086
  "slides[].ctaLabel": "slides[].cta_label",
@@ -6586,6 +6602,8 @@
6586
6602
  "KitImage.astro",
6587
6603
  "Section.astro",
6588
6604
  "behaviors.ts",
6605
+ "embed-frame.ts",
6606
+ "embed-target.ts",
6589
6607
  "image.ts",
6590
6608
  "measured.ts",
6591
6609
  "safe-url.ts",
@@ -4,14 +4,18 @@
4
4
  //
5
5
  // Each island is a root the Behavior component prints: `data-cr-behavior` names the kind, and the item, title and
6
6
  // panel selectors come from the engine (`data-cr-items`, `data-cr-title`, `data-cr-panel`), never from the source
7
- // (the writer strips every `data-cr-*` the source markup carried before it wraps it). The module reads only ARIA,
8
- // `details`, `hidden` and those attributes.
7
+ // (the engine's island pass strips every `data-cr-*` the source markup carried before it wraps it). The module reads only ARIA,
8
+ // `details`, `hidden` and those attributes, plus `data-cr-video` on a link the engine wrote after that strip (a source
9
+ // video player kept as a poster link to its page).
9
10
  //
10
11
  // Rules (t13's PR-E checklist §4): text goes in through `textContent` only, never `innerHTML` or its family; no
11
12
  // `eval`, `Function`, `fetch` or `location`; every selector is scoped to its island root and run in try/catch (a
12
13
  // selector the browser refuses leaves that island static); `init` is idempotent; anything that fails leaves the
13
14
  // island as it was printed: readable, every item in reading order. Reduced motion is honoured.
14
15
 
16
+ import { playerFrame, plainClick } from './embed-frame'
17
+ import { videoTarget } from './embed-target'
18
+
15
19
  type Kind = 'disclosure' | 'tablist' | 'carousel' | 'dialog' | 'countup' | 'toggle' | 'filter' | 'anchor' | 'lightbox'
16
20
 
17
21
  const KINDS: ReadonlySet<string> = new Set<Kind>(['disclosure', 'tablist', 'carousel', 'dialog', 'countup', 'toggle', 'filter', 'anchor', 'lightbox'])
@@ -232,6 +236,35 @@ function lightbox(root: HTMLElement): void {
232
236
  }
233
237
  }
234
238
 
239
+ /**
240
+ * A source video player the engine kept as a link to its page (`<a href=watch-url data-cr-video="youtube:<id>">`, a
241
+ * poster image inside): a plain click plays it in place. Nothing loads before the click, and the player's address is
242
+ * built from the checked provider and id (embed-target.ts), never from the link's `href` or the attribute's text: an
243
+ * id the parser refuses leaves the link as it is, opening the provider's page. The player takes the link's own box,
244
+ * so nothing around it moves.
245
+ */
246
+ function video(root: HTMLElement): void {
247
+ for (const link of within(root, 'a[href][data-cr-video]') as HTMLAnchorElement[]) {
248
+ // Only `youtube:<id>` / `vimeo:<id>`, the form the engine writes: never an address, so no text of the attribute can
249
+ // reach the frame. One inside a nested root came with that (spoofed) root's markup: never ours to wire.
250
+ const value = link.dataset.crVideo ?? ''
251
+ const found = /^(?:youtube|vimeo):[\w-]+$/.test(value) && link.parentElement?.closest('[data-cr-behavior]') === root ? videoTarget(value) : null
252
+ if (!found) continue
253
+ link.addEventListener('click', (event) => {
254
+ if (!plainClick(event)) return
255
+ // A poster's own box; a link with no picture (a title only) plays at 16:9, never as tall as one line of text.
256
+ const box = link.querySelector('img') ? link.getBoundingClientRect() : null
257
+ const name = link.getAttribute('aria-label') || link.textContent?.trim() || link.querySelector('img')?.alt || found.provider
258
+ const frame = playerFrame(found.src, name, 'cr-behavior-video')
259
+ if (!frame) return
260
+ event.preventDefault()
261
+ Object.assign(frame.style, { display: 'block', width: '100%', height: 'auto', border: '0', aspectRatio: box && box.width > 0 && box.height > 0 ? `${box.width} / ${box.height}` : '16 / 9' })
262
+ link.replaceWith(frame)
263
+ frame.focus()
264
+ })
265
+ }
266
+ }
267
+
235
268
  const WIRE: Record<Kind, (root: HTMLElement) => void> = { disclosure, tablist, carousel, dialog, countup, toggle, filter, anchor, lightbox }
236
269
 
237
270
  /** Wires every island on the page once. Safe to call again (a view transition, a second script copy). */
@@ -247,6 +280,9 @@ export function initBehaviors(scope: ParentNode = document): void {
247
280
  catch {
248
281
  // Left as printed: the content is all there, it just does not move.
249
282
  }
283
+ // A video link plays in place in any island: a player sat in a tab, a slide or a panel as often as on its own.
284
+ try { video(root) }
285
+ catch { /* its links stay links */ }
250
286
  // A dialog opener inside an island of another kind (a popup button in a tab): opened too, never left dead, even
251
287
  // where the island's own wiring failed.
252
288
  if (kind !== 'dialog') {
@@ -0,0 +1,22 @@
1
+ // The player a click on an embed poster becomes: one recipe for the kit Embed, a Slider slide and a behaviour
2
+ // island's video link, so the frame's permissions are written once. The address is checked again at the click: what
3
+ // the page carries (`data-embed-src`, which an edited page could change) goes through embed-target.ts once more, and
4
+ // the frame loads the address that builds from a known provider, never the attribute's text.
5
+ import { embedTarget } from './embed-target'
6
+
7
+ /** An iframe for the player at `src`, named `title`, filling its positioned frame; none when `src` is no known player. */
8
+ export function playerFrame(src: string | undefined, title: string, className = 'absolute inset-0 h-full w-full border-0'): HTMLIFrameElement | null {
9
+ const target = src ? embedTarget(src) : null
10
+ if (!target) return null
11
+ const frame = document.createElement('iframe')
12
+ frame.src = target.src
13
+ frame.title = title
14
+ frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write'
15
+ frame.allowFullscreen = true
16
+ frame.referrerPolicy = 'strict-origin-when-cross-origin'
17
+ frame.className = className
18
+ return frame
19
+ }
20
+
21
+ /** A plain left click: a modified one (new tab, new window) keeps the link's own behaviour. */
22
+ export const plainClick = (event: MouseEvent): boolean => !event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
@@ -107,3 +107,33 @@ export function mapTarget(address: string): EmbedTarget | null {
107
107
  const query = encodeURIComponent(q)
108
108
  return { provider: 'google-maps', kind: 'map', src: `https://www.google.com/maps?q=${query}&output=embed`, href: `https://www.google.com/maps/search/?api=1&query=${query}` }
109
109
  }
110
+
111
+ const VIDEO_PAGE: Record<string, (id: string) => string> = {
112
+ youtube: id => `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`,
113
+ vimeo: id => `https://vimeo.com/${encodeURIComponent(id)}`,
114
+ }
115
+
116
+ /**
117
+ * A video to play in place: a video page or player URL, or `youtube:<id>` / `vimeo:<id>` (what the engine writes on a
118
+ * slide or an island link). Only a video comes back (never a map or a form); the id is checked by the same parser as a
119
+ * URL, so the player address is always built here, never taken from the content.
120
+ */
121
+ export function videoTarget(value: string | undefined): EmbedTarget | null {
122
+ if (!value) return null
123
+ const short = /^(youtube|vimeo):([\w-]+)$/.exec(value.trim())
124
+ const target = embedTarget(short ? VIDEO_PAGE[short[1]!]!(short[2]!) : value)
125
+ return target?.kind === 'video' ? target : null
126
+ }
127
+
128
+ /** Hosts a video provider serves its players, thumbnails and streams from. */
129
+ const VIDEO_HOST = /(?:^|\.)(?:youtube|youtube-nocookie|youtu|ytimg|googlevideo|ggpht|vimeo|vimeocdn|loom|wistia)\.(?:com|be|net)$/i
130
+
131
+ /**
132
+ * An address on a video provider's own hosts (a YouTube thumbnail on i.ytimg.com, a Vimeo one on vimeocdn.com): never
133
+ * drawn as a poster, since loading it is a request to the provider before the reader asked for the video.
134
+ */
135
+ export function onVideoHost(src: string | undefined): boolean {
136
+ if (!src) return false
137
+ try { return VIDEO_HOST.test(new URL(src, 'https://site.invalid').hostname) }
138
+ catch { return false }
139
+ }
@@ -71,4 +71,12 @@ const NOSCRIPT = '<style>.cr-behavior dialog:not([open]){display:block;position:
71
71
  .cr-behavior-dialog { margin: auto; max-width: min(92vw, 40rem); max-height: 85dvh; border: 1px solid var(--color-line); border-radius: var(--radius-card, 0.75rem); padding: 1.5rem; }
72
72
  .cr-behavior-dialog::backdrop { background: rgb(0 0 0 / 0.5); }
73
73
  .cr-behavior-lightbox img { display: block; max-width: 100%; max-height: 75dvh; margin-inline: auto; }
74
+ /* A source video player kept as a poster link (data-cr-video): a play button over it, drawn in CSS (nothing loads). */
75
+ .cr-behavior a[data-cr-video] { position: relative; display: block; }
76
+ .cr-behavior a[data-cr-video] img { display: block; width: 100%; height: auto; }
77
+ .cr-behavior a[data-cr-video]::before { content: ''; position: absolute; z-index: 1; top: 50%; left: 50%; width: 4rem; height: 4rem; translate: -50% -50%; border-radius: 9999px; background: var(--color-accent); box-shadow: 0 4px 12px rgb(0 0 0 / 0.25); }
78
+ .cr-behavior a[data-cr-video]::after { content: ''; position: absolute; z-index: 2; top: 50%; left: 50%; translate: -35% -50%; border-top: 0.75rem solid transparent; border-bottom: 0.75rem solid transparent; border-left: 1.25rem solid var(--color-accent-ink, #fff); }
79
+ /* No poster (only the title): a 16:9 dark panel, the title along its foot, the button in its middle. */
80
+ .cr-behavior a[data-cr-video]:not(:has(img)) { aspect-ratio: 16 / 9; display: flex; align-items: flex-end; padding: 1rem 1.25rem; border-radius: var(--radius-card, 0.75rem); background: var(--color-ink, #111827); color: var(--color-surface, #fff); font-weight: 600; text-decoration: none; }
81
+ .cr-behavior a[data-cr-video]:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
74
82
  </style>
@@ -50,5 +50,7 @@
50
50
  "tone": "accent",
51
51
  "html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx3-a\">Do you ship abroad?</button></h2><div id=\"cr-fx3-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
52
52
  }
53
- }
53
+ },
54
+ {"name": "video-link", "props": {"kind": "anchor", "fact": "interactive:fx5", "html": "<h2>Studio tour</h2><p>A walk through the workshop.</p><a href=\"https://www.youtube.com/watch?v=aqz-KE-bpKQ\" target=\"_blank\" rel=\"noopener noreferrer\" data-cr-video=\"youtube:aqz-KE-bpKQ\"><img src=\"/kit-fixtures/photo-2.svg\" alt=\"\" loading=\"lazy\" width=\"1200\" height=\"800\"><span>Studio tour</span></a>"}},
55
+ {"name": "video-link-no-poster", "props": {"kind": "anchor", "fact": "interactive:fx6", "html": "<h2>Studio tour</h2><a href=\"https://vimeo.com/76979871\" target=\"_blank\" rel=\"noopener noreferrer\" data-cr-video=\"vimeo:76979871\"><span>Behind the scenes</span></a>"}}
54
56
  ]
@@ -7,6 +7,8 @@
7
7
  "shared": [
8
8
  "Section.astro",
9
9
  "behaviors.ts",
10
+ "embed-frame.ts",
11
+ "embed-target.ts",
10
12
  "measured.ts",
11
13
  "safe-url.ts",
12
14
  "types.ts"
@@ -8,7 +8,7 @@
8
8
  import { tv } from 'tailwind-variants'
9
9
  import KitImage from '../_shared/KitImage.astro'
10
10
  import type { ImageInput } from '../_shared/types'
11
- import { embedTarget, mapTarget } from './embed'
11
+ import { embedTarget, mapTarget } from '../_shared/embed-target'
12
12
 
13
13
  const embed = tv({
14
14
  slots: {
@@ -84,20 +84,17 @@ const linkUrl = url && /^https?:\/\//i.test(url) ? url : undefined
84
84
  )}
85
85
 
86
86
  <script>
87
+ import { playerFrame, plainClick } from '../_shared/embed-frame'
88
+
87
89
  // Click (or Enter) on the poster: the player replaces it, focused, so keyboard and screen-reader users land in it.
88
90
  // A modified click (new tab, new window) keeps the link's own behaviour.
89
91
  document.addEventListener('click', (event) => {
90
- if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
92
+ if (!plainClick(event)) return
91
93
  const link = (event.target as Element | null)?.closest<HTMLAnchorElement>('[data-kit-embed] a[data-embed-src]')
92
- if (!link?.dataset.embedSrc) return
94
+ // An address that is no known player stays what it is: a link.
95
+ const frame = link && playerFrame(link.dataset.embedSrc, link.dataset.embedTitle ?? '')
96
+ if (!link || !frame) return
93
97
  event.preventDefault()
94
- const frame = document.createElement('iframe')
95
- frame.src = link.dataset.embedSrc
96
- frame.title = link.dataset.embedTitle ?? ''
97
- frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write'
98
- frame.allowFullscreen = true
99
- frame.referrerPolicy = 'strict-origin-when-cross-origin'
100
- frame.className = 'absolute inset-0 h-full w-full border-0'
101
98
  link.replaceWith(frame)
102
99
  frame.focus()
103
100
  })
@@ -4,7 +4,7 @@
4
4
  "description": "A video, map or player from another site (YouTube, Vimeo, Google Maps, OpenStreetMap, Spotify, SoundCloud, Calendly, Typeform, Google Forms, Loom, Wistia), loaded only on click: until then a poster link to the provider's page, with no iframe, no third-party request and no cookie (YouTube through youtube-nocookie.com). Unknown providers are never framed; they stay a plain link.",
5
5
  "category": "content",
6
6
  "uses": [],
7
- "shared": ["KitImage.astro", "safe-url.ts", "types.ts"],
7
+ "shared": ["KitImage.astro", "embed-frame.ts", "embed-target.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "aspect": { "options": ["auto", "video", "portrait", "square", "map"], "default": "auto", "description": "auto: 16:9 for videos, 9:16 for YouTube Shorts, 4:3 (16:9 wide) for maps, a fixed strip for audio players. The others force a ratio." },
10
10
  "width": { "options": ["prose", "page"], "default": "prose", "description": "prose: as wide as the reading column. page: the full page frame." }
@@ -1,5 +1,5 @@
1
1
  ---
2
- // A carousel of images, videos or text cards, with optional captions. Without JavaScript it is a
2
+ // A carousel of images, videos (a file, or a YouTube/Vimeo one loaded only on click) or text cards, with optional captions. Without JavaScript it is a
3
3
  // horizontally scrolling, snapping row — every slide is reachable. With it,
4
4
  // Embla turns the row into a paged carousel with previous/next buttons and
5
5
  // slide dots. The markup and the script are in SliderTrack.astro; given a
@@ -15,6 +15,8 @@ interface Slide {
15
15
  imageAlt?: string | undefined
16
16
  /** A video file's address (mp4, webm): drawn in place of the image (its poster), played only when the reader asks. */
17
17
  video?: string | undefined
18
+ /** A video on another site (its YouTube/Vimeo/Loom/Wistia page, or `youtube:<id>` / `vimeo:<id>`): a poster until the reader clicks it. */
19
+ embed?: string | undefined
18
20
  title?: string | undefined
19
21
  text?: string | undefined
20
22
  /** The slide's link, flat so a page stays within Contentrain's nesting depth. */
@@ -39,6 +41,8 @@ interface Props {
39
41
  slideLabel?: string | undefined
40
42
  /** Word between a slide number and the count ("of"). */
41
43
  ofLabel?: string | undefined
44
+ /** An embedded video's play button, before its name ("Play"). */
45
+ playLabel?: string | undefined
42
46
  loop?: boolean | undefined
43
47
  perView?: '1' | '2' | '3' | undefined
44
48
  aspect?: 'wide' | 'standard' | 'square' | undefined
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  // A carousel of images with optional captions, of videos (played only when the
3
- // reader asks), or of text cards (a slide with no image: quotes, testimonials). Without JavaScript it is a
3
+ // reader asks; a YouTube or Vimeo one only once the reader clicks its poster), or of text cards (a slide with no image: quotes, testimonials). Without JavaScript it is a
4
4
  // horizontally scrolling, snapping row — every slide is reachable. With it,
5
5
  // Embla turns the row into a paged carousel with previous/next buttons and
6
6
  // slide dots. Slider.astro frames it (the section's own look, when a measured
@@ -11,6 +11,7 @@ import KitImage from '../_shared/KitImage.astro'
11
11
  import SectionHeader from '../_shared/SectionHeader.astro'
12
12
  import { imageOf, type ImageSource } from '../_shared/image'
13
13
  import { safeSrc } from '../_shared/safe-url'
14
+ import { onVideoHost, videoTarget, type EmbedTarget } from '../_shared/embed-target'
14
15
  import { measuredSlides } from '../_shared/measured'
15
16
  import { type ActionInput, type ImageInput, type MeasuredStyle } from '../_shared/types'
16
17
 
@@ -53,6 +54,13 @@ interface Slide {
53
54
  * the reader plays it, never plays by itself, and pauses when its slide is paged away. The caption sits under it.
54
55
  */
55
56
  video?: string | undefined
57
+ /**
58
+ * A video on another site: its page (YouTube, Vimeo, Loom, Wistia) or `youtube:<id>` / `vimeo:<id>`. Drawn as its
59
+ * poster (the image, else a dark panel) with a play button; nothing from the provider loads until the reader clicks,
60
+ * and then the player takes the poster's place (YouTube from youtube-nocookie.com, Vimeo with dnt). Paged away, it
61
+ * goes back to its poster. Wins over `video`; an address that is not one of these videos is not drawn.
62
+ */
63
+ embed?: string | undefined
56
64
  title?: string | undefined
57
65
  text?: string | undefined
58
66
  /** The slide's link, flat so a page stays within Contentrain's nesting depth. */
@@ -77,6 +85,8 @@ interface Props {
77
85
  slideLabel?: string | undefined
78
86
  /** Word between a slide number and the count ("of"). */
79
87
  ofLabel?: string | undefined
88
+ /** An embedded video's play button, before its name ("Play"). */
89
+ playLabel?: string | undefined
80
90
  loop?: boolean | undefined
81
91
  perView?: '1' | '2' | '3' | undefined
82
92
  aspect?: 'wide' | 'standard' | 'square' | undefined
@@ -89,15 +99,19 @@ interface Props {
89
99
  framed?: boolean | undefined
90
100
  }
91
101
 
92
- const { slides: given, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', loop = false, perView = '1', aspect, caption, heading, intro, eyebrow, actions, measured, id = 'slider', framed = true } = Astro.props
93
- // A slide is drawn when it has something to show: an image, a video, or a text card (a title, a text or a link) where
102
+ const { slides: given, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', playLabel = 'Play', loop = false, perView = '1', aspect, caption, heading, intro, eyebrow, actions, measured, id = 'slider', framed = true } = Astro.props
103
+ // A slide is drawn when it has something to show: an image, a video (a file or another site's), or a text card (a title, a text or a link) where
94
104
  // the source carousel moved quotes or testimonials. A slide with none of them is dropped.
95
105
  const slides = given.flatMap((slide) => {
96
- const image = slide.image === undefined ? undefined : imageOf(slide.image, slide.imageAlt)
97
- const video = safeSrc(slide.video) || undefined
106
+ const player = videoTarget(slide.embed) ?? undefined
107
+ // A video's poster is the site's own image: one on the provider's hosts (its thumbnail) would be a request to it
108
+ // before the reader asked, so the poster is a plain dark panel instead.
109
+ const picture = slide.image === undefined ? undefined : imageOf(slide.image, slide.imageAlt)
110
+ const image = player && onVideoHost(picture?.src) ? undefined : picture
111
+ const video = player ? undefined : safeSrc(slide.video) || undefined
98
112
  const action = slide.action ?? (slide.ctaLabel && slide.ctaHref ? { label: slide.ctaLabel, href: slide.ctaHref } : undefined)
99
- return image || video || slide.title || slide.text || action ? [{ ...slide, image, video, action }] : []
100
- }) satisfies Array<{ image: ImageInput | undefined, video: string | undefined, action?: ActionInput | undefined }>
113
+ return image || video || player || slide.title || slide.text || action ? [{ ...slide, image, video, player, action }] : []
114
+ }) satisfies Array<{ image: ImageInput | undefined, video: string | undefined, player: EmbedTarget | undefined, action?: ActionInput | undefined }>
101
115
  const measuredRow = measuredSlides(measured)
102
116
  // A measured row sets the slides in view itself, so the variant's own widths step aside.
103
117
  const slots = slider({ perView: measuredRow ? '1' : perView, aspect, caption })
@@ -110,7 +124,9 @@ const Root = framed ? 'section' : 'div'
110
124
  const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-center justify-center rounded-full bg-surface/90 text-ink shadow-md ring-1 ring-line hover:bg-surface disabled:opacity-40 sm:group-data-[ready]:flex'
111
125
  // A text card has no picture for the arrows to sit on: over it they would cover its words. A carousel with any text
112
126
  // card (all of them, or mixed with pictures) has its arrows under the row, either side of the dots.
113
- const cards = slides.some(slide => !slide.image && !slide.video)
127
+ const cards = slides.some(slide => !slide.image && !slide.video && !slide.player)
128
+ // A video from another site is a link to its page, so without JavaScript it opens there; its frame keeps the image's
129
+ // ratio, so the player that replaces the link moves nothing.
114
130
  const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg-surface text-ink ring-1 ring-line hover:bg-surface disabled:opacity-40'
115
131
  ---
116
132
  <Root class:list={['group relative', framed && 'container-page py-8']} role={framed ? undefined : 'region'} aria-roledescription="carousel" aria-label={label} data-kit-slider data-loop={loop ? '' : undefined} data-slide-label={slideLabel}>
@@ -119,11 +135,22 @@ const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg
119
135
  <div class:list={['flex gap-4 [&>*]:shrink-0 group-data-[ready]:[&>*]:snap-align-none', measuredRow?.track]} id={trackId} data-container>
120
136
  {slides.map((slide, index) => (
121
137
  <div class={slots.slide({ class: measuredRow?.slide })} role="group" aria-roledescription="slide" aria-label={`${slideLabel} ${index + 1} ${ofLabel} ${slides.length}`}>
122
- {slide.video
138
+ {slide.player
139
+ ? (
140
+ <div class={below.image({ class: 'relative overflow-hidden bg-ink' })} data-slide-player>
141
+ <a href={slide.player.href} class="group/play absolute inset-0 block focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-accent" data-embed-src={slide.player.src} data-embed-title={slide.title || slide.image?.alt || `${slideLabel} ${index + 1}`} aria-label={`${playLabel}: ${slide.title || slide.image?.alt || `${slideLabel} ${index + 1}`}`} rel="noopener">
142
+ {slide.image && <KitImage image={{ ...slide.image, alt: '' }} sizes={sizes} priority={index === 0} class="absolute inset-0 h-full w-full object-cover opacity-85 transition-opacity group-hover/play:opacity-70" />}
143
+ <span class="absolute top-1/2 left-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-accent text-accent-ink shadow-lg transition-transform group-hover/play:scale-105 group-focus-visible/play:scale-105" aria-hidden="true">
144
+ <svg viewBox="0 0 24 24" class="ms-1 size-7" fill="currentColor"><path d="M7 4.5v15a.5.5 0 0 0 .77.42l11.5-7.5a.5.5 0 0 0 0-.84L7.77 4.08A.5.5 0 0 0 7 4.5Z" /></svg>
145
+ </span>
146
+ </a>
147
+ </div>
148
+ )
149
+ : slide.video
123
150
  ? <video class={below.image({ class: 'bg-ink' })} src={safeSrc(slide.video)} poster={safeSrc(slide.image?.src)} controls preload="none" playsinline aria-label={slide.title ? undefined : slide.image?.alt || `${slideLabel} ${index + 1}`} />
124
151
  : slide.image && <KitImage image={slide.image} sizes={sizes} priority={index === 0} class={slots.image()} />}
125
152
  {(slide.title || slide.text || slide.action) && (
126
- <div class={slide.video ? below.caption() : slide.image ? slots.caption() : slots.card()}>
153
+ <div class={slide.video || slide.player ? below.caption() : slide.image ? slots.caption() : slots.card()}>
127
154
  {slide.title && <p class="text-xl [font-weight:var(--font-weight-heading,600)] text-balance">{slide.title}</p>}
128
155
  {slide.text && <p class="opacity-85">{slide.text}</p>}
129
156
  {slide.action && <Button action={slide.action} size="sm" class="mt-2" />}
@@ -152,6 +179,7 @@ const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg
152
179
 
153
180
  <script>
154
181
  import EmblaCarousel from 'embla-carousel'
182
+ import { playerFrame, plainClick } from '../_shared/embed-frame'
155
183
 
156
184
  for (const root of document.querySelectorAll<HTMLElement>('[data-kit-slider]')) {
157
185
  const viewport = root.querySelector<HTMLElement>('[data-viewport]')
@@ -204,7 +232,29 @@ const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg
204
232
  }
205
233
  // A video paged out of view stops: nothing plays where the reader cannot see it.
206
234
  const pause = () => { for (const video of root.querySelectorAll('video')) if (!video.paused) video.pause() }
207
- embla.on('select', () => { pause(); sync() }).on('reInit', () => { drawDots(); sync() })
235
+ // Another site's video: its poster link becomes the player on a plain click (a swipe that ends on it is not one:
236
+ // Embla cancels that click), and goes back to its poster when its slide is paged out of view, which stops it and
237
+ // ends the provider's session there.
238
+ const posters = new Map<HTMLIFrameElement, HTMLAnchorElement>()
239
+ viewport.addEventListener('click', (event) => {
240
+ if (!plainClick(event)) return
241
+ const link = (event.target as Element | null)?.closest<HTMLAnchorElement>('[data-slide-player] a[data-embed-src]')
242
+ const frame = link && playerFrame(link.dataset.embedSrc, link.dataset.embedTitle ?? '')
243
+ if (!link || !frame) return
244
+ event.preventDefault()
245
+ posters.set(frame, link)
246
+ link.replaceWith(frame)
247
+ frame.focus()
248
+ })
249
+ const unplay = () => {
250
+ const inView = new Set(embla.slidesInView().map(index => embla.slideNodes()[index]))
251
+ for (const [frame, link] of posters) {
252
+ if (inView.has(frame.closest<HTMLElement>('[aria-roledescription="slide"]') ?? undefined)) continue
253
+ frame.replaceWith(link)
254
+ posters.delete(frame)
255
+ }
256
+ }
257
+ embla.on('select', () => { pause(); sync() }).on('settle', unplay).on('reInit', () => { drawDots(); sync() })
208
258
  sync()
209
259
  // Set up before the layout was final, Embla measures once more when it is: the page loaded, its fonts in, the tab in
210
260
  // view. A background tab lays nothing out (ctrl/cmd-click), so its carousel had one snap and a dead "next" until then;
@@ -5,5 +5,6 @@
5
5
  { "name": "five-measured", "props": { "label": "Five slides", "id": "five", "eyebrow": "Recent work", "heading": "Five projects", "intro": "A few of the sites we moved this year.", "actions": [{ "label": "See all work", "href": "/work/" }], "slides": [{ "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "View 1", "width": 1200, "height": 800 }, "title": "Slide 1 title" }, { "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "View 2", "width": 1200, "height": 800 }, "title": "Slide 2 title" }, { "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "View 3", "width": 1200, "height": 800 }, "title": "Slide 3 title" }, { "image": { "src": "/kit-fixtures/photo-4.svg", "alt": "View 4", "width": 1200, "height": 800 }, "title": "Slide 4 title" }, { "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "View 5", "width": 1200, "height": 800 }, "title": "Slide 5 title" }], "measured": { "padY": 24, "columns": { "390": 1, "768": 2, "1280": 3 }, "gap": 20 } } },
6
6
  { "name": "text-cards", "props": { "label": "What clients say", "perView": "3", "heading": "Testimonials", "slides": [{ "title": "Ada Lovelace", "text": "They moved our whole site in a week, and nothing broke." }, { "title": "Grace Hopper", "text": "Every page is ours to edit now, without a developer." }, { "title": "Alan Turing", "text": "The new site is faster, and it looks the same.", "ctaLabel": "Read the story", "ctaHref": "/stories/" }] } },
7
7
  { "name": "video-slides", "props": { "label": "Feature tour", "heading": "See it work", "slides": [{ "video": "/kit-fixtures/clip-1.mp4", "image": "/kit-fixtures/photo-1.svg", "imageAlt": "", "title": "Import" }, { "video": "/kit-fixtures/clip-2.mp4", "title": "Edit" }, { "video": "/kit-fixtures/clip-3.mp4", "image": "/kit-fixtures/photo-3.svg", "imageAlt": "", "title": "Publish", "text": "One click, and the site is live." }] } },
8
- { "name": "mixed-cards", "props": { "label": "Stories", "slides": [{ "title": "Ada Lovelace", "text": "They moved our whole site in a week, and nothing broke." }, { "image": "/kit-fixtures/photo-2.svg", "imageAlt": "Green gradient", "title": "Harbor & Co" }, { "title": "Grace Hopper", "text": "Every page is ours to edit now, without a developer." }] } }
8
+ { "name": "mixed-cards", "props": { "label": "Stories", "slides": [{ "title": "Ada Lovelace", "text": "They moved our whole site in a week, and nothing broke." }, { "image": "/kit-fixtures/photo-2.svg", "imageAlt": "Green gradient", "title": "Harbor & Co" }, { "title": "Grace Hopper", "text": "Every page is ours to edit now, without a developer." }] } },
9
+ {"name": "video-embed", "props": {"label": "Studio videos", "id": "videos", "aspect": "standard", "perView": "2", "slides": [{"embed": "youtube:aqz-KE-bpKQ", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "Blue gradient", "width": 1200, "height": 800}, "title": "Studio tour"}, {"embed": "https://vimeo.com/76979871", "title": "Behind the scenes"}, {"embed": "youtube:aqz-KE-bpKQ", "image": "https://i.ytimg.com/vi/aqz-KE-bpKQ/hqdefault.jpg", "title": "A provider thumbnail is never loaded"}, {"image": {"src": "/kit-fixtures/photo-3.svg", "alt": "Orange gradient", "width": 1200, "height": 800}, "title": "Field Notes"}]}}
9
10
  ]
@@ -1,22 +1,23 @@
1
1
  {
2
2
  "id": "slider",
3
3
  "name": "Slider",
4
- "description": "A carousel of images, videos or text cards, with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
4
+ "description": "A carousel of images, videos (a file, or a YouTube/Vimeo link loaded only on click) or text cards, with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "image.ts", "measured.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "embed-frame.ts", "embed-target.ts", "image.ts", "measured.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "perView": { "options": ["1", "2", "3"], "default": "1", "description": "Slides visible at once on wide screens." },
10
10
  "aspect": { "options": ["wide", "standard", "square"], "default": "wide", "description": "Image aspect ratio: 21:9, 3:2 or 1:1." },
11
11
  "caption": { "options": ["below", "overlay"], "default": "overlay", "description": "Caption under the image, or over its lower edge on a gradient." }
12
12
  },
13
13
  "props": {
14
- "slides": { "type": "array", "label": "Slides", "required": true, "items": { "type": "object", "fields": { "image": { "type": "image", "label": "Image", "description": "The slide image; a slide without one is a text card (a quote, a testimonial)." }, "imageAlt": { "type": "string", "label": "Image description", "description": "What the image shows, for readers who cannot see it." }, "video": { "type": "video", "label": "Video", "description": "A video file (mp4, webm) shown in place of the image, which is its poster. It plays only when the reader starts it." }, "title": { "type": "string", "label": "Title", "description": "Heading over the slide." }, "text": { "type": "text", "label": "Text", "description": "A line or two under the heading." }, "ctaLabel": { "type": "string", "label": "Button label", "description": "Button text; the slide has no button without it." }, "ctaHref": { "type": "url", "label": "Button link", "description": "Where the button goes." } } }, "description": "Slides: the image's address and text, an optional video (the image is its poster), and an optional link (ctaLabel + ctaHref). A slide with no image or video is a text card; one with none of them is not drawn." },
14
+ "slides": { "type": "array", "label": "Slides", "required": true, "items": { "type": "object", "fields": { "image": { "type": "image", "label": "Image", "description": "The slide image; a slide without one is a text card (a quote, a testimonial)." }, "imageAlt": { "type": "string", "label": "Image description", "description": "What the image shows, for readers who cannot see it." }, "video": { "type": "video", "label": "Video", "description": "A video file (mp4, webm) shown in place of the image, which is its poster. It plays only when the reader starts it." }, "embed": { "type": "url", "label": "Video link", "description": "A video on another site: its YouTube, Vimeo, Loom or Wistia page (or youtube:<id> / vimeo:<id>). Shown as the image with a play button; nothing from that site loads until the reader clicks it. Wins over Video." }, "title": { "type": "string", "label": "Title", "description": "Heading over the slide." }, "text": { "type": "text", "label": "Text", "description": "A line or two under the heading." }, "ctaLabel": { "type": "string", "label": "Button label", "description": "Button text; the slide has no button without it." }, "ctaHref": { "type": "url", "label": "Button link", "description": "Where the button goes." } } }, "description": "Slides: the image's address and text, an optional video file or video link (the image is its poster), and an optional link (ctaLabel + ctaHref). A slide with no image or video is a text card; one with none of them is not drawn." },
15
15
  "label": { "type": "string", "required": true, "content": false, "description": "Accessible name of the carousel." },
16
16
  "prevLabel": { "type": "string", "content": false, "description": "ui-strings slider.prev." },
17
17
  "nextLabel": { "type": "string", "content": false, "description": "ui-strings slider.next." },
18
18
  "slideLabel": { "type": "string", "content": false, "description": "ui-strings slider.slide." },
19
19
  "ofLabel": { "type": "string", "content": false, "description": "ui-strings slider.of." },
20
+ "playLabel": { "type": "string", "content": false, "description": "ui-strings embed.play." },
20
21
  "loop": { "type": "boolean", "default": false, "content": false, "description": "Wrap from the last slide to the first." },
21
22
  "id": { "type": "string", "default": "slider", "content": false, "description": "Unique on the page: names the track that the previous/next buttons control (aria-controls)." },
22
23
  "heading": {"type": "string", "label": "Heading", "description": "The section heading."},
@@ -34,5 +35,5 @@
34
35
  "divi": ["divi/et_pb_slider", "divi/et_pb_fullwidth_slider", "divi/et_pb_post_slider"],
35
36
  "classic": ["classic/revslider", "classic/metaslider", "classic/smart-slider", "classic/swiper", "classic/slick", "classic/splide", "classic/owl"]
36
37
  },
37
- "a11y": "WAI-ARIA carousel pattern: a labelled region with aria-roledescription=carousel, slides as labelled groups ('Slide 2 of 5'), labelled previous/next buttons disabled at the ends, and dot buttons marking the current slide. The track and the buttons are tied by aria-controls, the row takes focus (arrow keys page it, polite live announcement). There is no autoplay, so nothing moves unasked: a video slide loads nothing until the reader plays it, never starts by itself, keeps its native controls with its caption under them, and pauses when its slide is paged away; with prefers-reduced-motion slides change without animation. Without JavaScript every slide is reachable by scrolling."
38
+ "a11y": "WAI-ARIA carousel pattern: a labelled region with aria-roledescription=carousel, slides as labelled groups ('Slide 2 of 5'), labelled previous/next buttons disabled at the ends, and dot buttons marking the current slide. The track and the buttons are tied by aria-controls, the row takes focus (arrow keys page it, polite live announcement). There is no autoplay, so nothing moves unasked: a video slide loads nothing until the reader plays it, never starts by itself, keeps its native controls with its caption under them, and pauses when its slide is paged away; a video link is a labelled poster link ('Play: title') to the video's page that becomes the player only on a click (no request to the provider before it; YouTube from youtube-nocookie.com, Vimeo with dnt), keeps the poster's box so nothing moves, takes focus, and goes back to its poster when its slide is paged away; with prefers-reduced-motion slides change without animation. Without JavaScript every slide is reachable by scrolling."
38
39
  }
@@ -9,6 +9,8 @@
9
9
  "KitImage.astro",
10
10
  "Section.astro",
11
11
  "behaviors.ts",
12
+ "embed-frame.ts",
13
+ "embed-target.ts",
12
14
  "image.ts",
13
15
  "measured.ts",
14
16
  "safe-url.ts",
@@ -102,7 +102,8 @@
102
102
  "prevLabel": "ui:slider.prev",
103
103
  "nextLabel": "ui:slider.next",
104
104
  "slideLabel": "ui:slider.slide",
105
- "ofLabel": "ui:slider.of"
105
+ "ofLabel": "ui:slider.of",
106
+ "playLabel": "ui:embed.play"
106
107
  },
107
108
  "into": "slides",
108
109
  "each": "rs-slide, .tp-revslider-slidesli",
@@ -120,7 +121,8 @@
120
121
  "prevLabel": "ui:slider.prev",
121
122
  "nextLabel": "ui:slider.next",
122
123
  "slideLabel": "ui:slider.slide",
123
- "ofLabel": "ui:slider.of"
124
+ "ofLabel": "ui:slider.of",
125
+ "playLabel": "ui:embed.play"
124
126
  },
125
127
  "into": "slides",
126
128
  "each": ".slides > li",
@@ -138,7 +140,8 @@
138
140
  "prevLabel": "ui:slider.prev",
139
141
  "nextLabel": "ui:slider.next",
140
142
  "slideLabel": "ui:slider.slide",
141
- "ofLabel": "ui:slider.of"
143
+ "ofLabel": "ui:slider.of",
144
+ "playLabel": "ui:embed.play"
142
145
  },
143
146
  "into": "slides",
144
147
  "each": ".n2-ss-slide",
@@ -431,7 +434,8 @@
431
434
  "prevLabel": "ui:slider.prev",
432
435
  "nextLabel": "ui:slider.next",
433
436
  "slideLabel": "ui:slider.slide",
434
- "ofLabel": "ui:slider.of"
437
+ "ofLabel": "ui:slider.of",
438
+ "playLabel": "ui:embed.play"
435
439
  },
436
440
  "into": "slides",
437
441
  "each": ".swiper-slide:not(.swiper-slide-duplicate)",
@@ -450,7 +454,8 @@
450
454
  "prevLabel": "ui:slider.prev",
451
455
  "nextLabel": "ui:slider.next",
452
456
  "slideLabel": "ui:slider.slide",
453
- "ofLabel": "ui:slider.of"
457
+ "ofLabel": "ui:slider.of",
458
+ "playLabel": "ui:embed.play"
454
459
  },
455
460
  "into": "slides",
456
461
  "each": ".slick-slide:not(.slick-cloned)",
@@ -469,7 +474,8 @@
469
474
  "prevLabel": "ui:slider.prev",
470
475
  "nextLabel": "ui:slider.next",
471
476
  "slideLabel": "ui:slider.slide",
472
- "ofLabel": "ui:slider.of"
477
+ "ofLabel": "ui:slider.of",
478
+ "playLabel": "ui:embed.play"
473
479
  },
474
480
  "into": "slides",
475
481
  "each": ".splide__slide:not(.splide__slide--clone)",
@@ -488,7 +494,8 @@
488
494
  "prevLabel": "ui:slider.prev",
489
495
  "nextLabel": "ui:slider.next",
490
496
  "slideLabel": "ui:slider.slide",
491
- "ofLabel": "ui:slider.of"
497
+ "ofLabel": "ui:slider.of",
498
+ "playLabel": "ui:embed.play"
492
499
  },
493
500
  "into": "slides",
494
501
  "each": ".owl-item:not(.cloned)",
package/mapping/divi.json CHANGED
@@ -124,7 +124,8 @@
124
124
  "prevLabel": "ui:slider.prev",
125
125
  "nextLabel": "ui:slider.next",
126
126
  "slideLabel": "ui:slider.slide",
127
- "ofLabel": "ui:slider.of"
127
+ "ofLabel": "ui:slider.of",
128
+ "playLabel": "ui:embed.play"
128
129
  },
129
130
  "into": "slides",
130
131
  "each": ".et_pb_slide",
@@ -145,7 +146,8 @@
145
146
  "prevLabel": "ui:slider.prev",
146
147
  "nextLabel": "ui:slider.next",
147
148
  "slideLabel": "ui:slider.slide",
148
- "ofLabel": "ui:slider.of"
149
+ "ofLabel": "ui:slider.of",
150
+ "playLabel": "ui:embed.play"
149
151
  },
150
152
  "into": "slides",
151
153
  "each": ".et_pb_slide",
@@ -167,7 +169,8 @@
167
169
  "prevLabel": "ui:slider.prev",
168
170
  "nextLabel": "ui:slider.next",
169
171
  "slideLabel": "ui:slider.slide",
170
- "ofLabel": "ui:slider.of"
172
+ "ofLabel": "ui:slider.of",
173
+ "playLabel": "ui:embed.play"
171
174
  },
172
175
  "into": "slides",
173
176
  "item": {
@@ -174,7 +174,8 @@
174
174
  "prevLabel": "ui:slider.prev",
175
175
  "nextLabel": "ui:slider.next",
176
176
  "slideLabel": "ui:slider.slide",
177
- "ofLabel": "ui:slider.of"
177
+ "ofLabel": "ui:slider.of",
178
+ "playLabel": "ui:embed.play"
178
179
  },
179
180
  "into": "slides",
180
181
  "each": ".swiper-slide:not(.swiper-slide-duplicate)",
@@ -192,7 +193,8 @@
192
193
  "prevLabel": "ui:slider.prev",
193
194
  "nextLabel": "ui:slider.next",
194
195
  "slideLabel": "ui:slider.slide",
195
- "ofLabel": "ui:slider.of"
196
+ "ofLabel": "ui:slider.of",
197
+ "playLabel": "ui:embed.play"
196
198
  },
197
199
  "into": "slides",
198
200
  "each": ".swiper-slide:not(.swiper-slide-duplicate)",
@@ -209,7 +211,8 @@
209
211
  "prevLabel": "ui:slider.prev",
210
212
  "nextLabel": "ui:slider.next",
211
213
  "slideLabel": "ui:slider.slide",
212
- "ofLabel": "ui:slider.of"
214
+ "ofLabel": "ui:slider.of",
215
+ "playLabel": "ui:embed.play"
213
216
  },
214
217
  "into": "slides",
215
218
  "each": ".swiper-slide:not(.swiper-slide-duplicate)",
@@ -287,7 +290,8 @@
287
290
  "prevLabel": "ui:slider.prev",
288
291
  "nextLabel": "ui:slider.next",
289
292
  "slideLabel": "ui:slider.slide",
290
- "ofLabel": "ui:slider.of"
293
+ "ofLabel": "ui:slider.of",
294
+ "playLabel": "ui:embed.play"
291
295
  },
292
296
  "into": "slides",
293
297
  "each": ".swiper-slide:not(.swiper-slide-duplicate)",
@@ -306,7 +310,8 @@
306
310
  "prevLabel": "ui:slider.prev",
307
311
  "nextLabel": "ui:slider.next",
308
312
  "slideLabel": "ui:slider.slide",
309
- "ofLabel": "ui:slider.of"
313
+ "ofLabel": "ui:slider.of",
314
+ "playLabel": "ui:embed.play"
310
315
  },
311
316
  "into": "slides",
312
317
  "each": ".slider-wrapper[data-slider-options] > *",
@@ -326,7 +331,8 @@
326
331
  "prevLabel": "ui:slider.prev",
327
332
  "nextLabel": "ui:slider.next",
328
333
  "slideLabel": "ui:slider.slide",
329
- "ofLabel": "ui:slider.of"
334
+ "ofLabel": "ui:slider.of",
335
+ "playLabel": "ui:embed.play"
330
336
  },
331
337
  "into": "slides",
332
338
  "item": {
@@ -1172,7 +1178,8 @@
1172
1178
  "prevLabel": "ui:slider.prev",
1173
1179
  "nextLabel": "ui:slider.next",
1174
1180
  "slideLabel": "ui:slider.slide",
1175
- "ofLabel": "ui:slider.of"
1181
+ "ofLabel": "ui:slider.of",
1182
+ "playLabel": "ui:embed.play"
1176
1183
  },
1177
1184
  "into": "slides",
1178
1185
  "each": "@slides .swiper-slide:not(.swiper-slide-duplicate)",
@@ -219,7 +219,8 @@
219
219
  "prevLabel": "ui:slider.prev",
220
220
  "nextLabel": "ui:slider.next",
221
221
  "slideLabel": "ui:slider.slide",
222
- "ofLabel": "ui:slider.of"
222
+ "ofLabel": "ui:slider.of",
223
+ "playLabel": "ui:embed.play"
223
224
  },
224
225
  "into": "slides",
225
226
  "each": "figure.wp-block-image",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "license": "MIT",
5
5
  "description": "Copy-in Astro components for Contentrain sites (shadcn model): typed props, tailwind-variants, zero JavaScript by default — with a machine-readable catalog",
6
6
  "type": "module",