@akinon/pz-theme 2.0.94-rc.0 → 2.0.95-beta.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
@@ -1,38 +1,21 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.94-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b]
34
- - @akinon/next@2.0.94-rc.0
35
- - @akinon/theme-contract@2.0.94-rc.0
3
+ ## 2.0.95-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.95-beta.0
9
+ - @akinon/theme-contract@2.0.95-beta.0
10
+
11
+ ## 2.0.94
12
+
13
+ ### Patch Changes
14
+
15
+ - 6f3189f3: Fix a hydration mismatch on `data-scroll-state`: mounting one chrome slot no longer stamps a sibling slot that has not hydrated yet under Suspense
16
+ - 57e09d65: ZERO-4988: Add mobile video/poster override and a custom play/pause button to the video block, and fix autoplay/loop/muted/controls/playsInline being read as raw per-breakpoint objects instead of resolved values (caused a hydration mismatch on `muted`)
17
+ - @akinon/next@2.0.94
18
+ - @akinon/theme-contract@2.0.94
36
19
 
37
20
  ## 2.0.93
38
21
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.94-rc.0",
4
+ "version": "2.0.95-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.94-rc.0",
11
+ "@akinon/next": "2.0.95-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.94-rc.0",
16
+ "@akinon/theme-contract": "2.0.95-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.94-rc.0",
24
+ "@akinon/next": "2.0.95-beta.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -441,12 +441,257 @@ describe('VideoBlock', () => {
441
441
  });
442
442
  });
443
443
 
444
+ describe('filling a full-bleed hero (block Height + Object Fit)', () => {
445
+ // Mirrors image-block.tsx: the `<video>` reads the same `height` /
446
+ // `object-fit` styles as the block wrapper, so setting Height: 100% +
447
+ // Position: Absolute + inset 0 on the block makes the player actually
448
+ // fill it — previously the player was stuck at `height: auto`, sized by
449
+ // its own aspect ratio rather than the box the author placed it in.
450
+ it('applies the authored height to the player, not just the wrapper', () => {
451
+ const { container } = renderBlock(
452
+ 'https://cdn.example.com/clip.mp4',
453
+ undefined,
454
+ { height: { desktop: '100%' } }
455
+ );
456
+ const video = container.querySelector('video') as HTMLVideoElement;
457
+ expect(video.style.height).toBe('100%');
458
+ });
459
+
460
+ it('defaults Object Fit to cover even without an authored aspect ratio', () => {
461
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4');
462
+ const video = container.querySelector('video') as HTMLVideoElement;
463
+ expect(video.style.objectFit).toBe('cover');
464
+ });
465
+
466
+ it('honors an explicit Object Fit override', () => {
467
+ const { container } = renderBlock(
468
+ 'https://cdn.example.com/clip.mp4',
469
+ undefined,
470
+ { 'object-fit': { desktop: 'contain' } }
471
+ );
472
+ const video = container.querySelector('video') as HTMLVideoElement;
473
+ expect(video.style.objectFit).toBe('contain');
474
+ });
475
+ });
476
+
444
477
  describe('non-string value (BUG-4 fix)', () => {
445
478
  it('renders the invalid placeholder instead of crashing on a non-string value', () => {
446
479
  renderBlock(123 as any);
447
480
  expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
448
481
  });
449
482
  });
483
+
484
+ describe('mobile video / poster override', () => {
485
+ const renderAt = (
486
+ value: any,
487
+ properties: any,
488
+ currentBreakpoint: 'desktop' | 'tablet' | 'mobile'
489
+ ) =>
490
+ render(
491
+ <VideoBlock
492
+ block={{ value, properties } as any}
493
+ placeholderId="p1"
494
+ sectionId="s1"
495
+ isDesigner={false}
496
+ currentBreakpoint={currentBreakpoint}
497
+ />
498
+ );
499
+
500
+ it('plays the desktop file on mobile when useDifferentMobileVideo is off', () => {
501
+ const { container } = renderAt(
502
+ 'https://cdn.example.com/desktop.mp4',
503
+ { mobileUrl: 'https://cdn.example.com/mobile.mp4' },
504
+ 'mobile'
505
+ );
506
+ expect(
507
+ (container.querySelector('video') as HTMLVideoElement).src
508
+ ).toBe('https://cdn.example.com/desktop.mp4');
509
+ });
510
+
511
+ it('plays the mobile file on mobile when useDifferentMobileVideo is on', () => {
512
+ const { container } = renderAt(
513
+ 'https://cdn.example.com/desktop.mp4',
514
+ {
515
+ useDifferentMobileVideo: true,
516
+ mobileUrl: 'https://cdn.example.com/mobile.mp4'
517
+ },
518
+ 'mobile'
519
+ );
520
+ expect(
521
+ (container.querySelector('video') as HTMLVideoElement).src
522
+ ).toBe('https://cdn.example.com/mobile.mp4');
523
+ });
524
+
525
+ it('still plays the desktop file on desktop even when a mobile override is set', () => {
526
+ const { container } = renderAt(
527
+ 'https://cdn.example.com/desktop.mp4',
528
+ {
529
+ useDifferentMobileVideo: true,
530
+ mobileUrl: 'https://cdn.example.com/mobile.mp4'
531
+ },
532
+ 'desktop'
533
+ );
534
+ expect(
535
+ (container.querySelector('video') as HTMLVideoElement).src
536
+ ).toBe('https://cdn.example.com/desktop.mp4');
537
+ });
538
+
539
+ it('uses the mobile poster on mobile when the override is on', () => {
540
+ const { container } = renderAt(
541
+ 'https://cdn.example.com/desktop.mp4',
542
+ {
543
+ poster: 'https://cdn.example.com/desktop.jpg',
544
+ useDifferentMobileVideo: true,
545
+ mobilePoster: 'https://cdn.example.com/mobile.jpg'
546
+ },
547
+ 'mobile'
548
+ );
549
+ expect(
550
+ (container.querySelector('video') as HTMLVideoElement).poster
551
+ ).toBe('https://cdn.example.com/mobile.jpg');
552
+ });
553
+
554
+ it('falls back to the desktop poster on mobile without an override', () => {
555
+ const { container } = renderAt(
556
+ 'https://cdn.example.com/desktop.mp4',
557
+ { poster: 'https://cdn.example.com/desktop.jpg' },
558
+ 'mobile'
559
+ );
560
+ expect(
561
+ (container.querySelector('video') as HTMLVideoElement).poster
562
+ ).toBe('https://cdn.example.com/desktop.jpg');
563
+ });
564
+ });
565
+
566
+ describe('custom play/pause button', () => {
567
+ beforeAll(() => {
568
+ // jsdom does not implement media playback.
569
+ window.HTMLMediaElement.prototype.play = jest.fn();
570
+ window.HTMLMediaElement.prototype.pause = jest.fn();
571
+ });
572
+
573
+ it('is not rendered when native controls are on, even if requested', () => {
574
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
575
+ controls: true,
576
+ customPlayPause: true
577
+ });
578
+ expect(container.querySelector('button')).not.toBeInTheDocument();
579
+ });
580
+
581
+ it('is not rendered when controls are off but the toggle is off', () => {
582
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
583
+ controls: false,
584
+ customPlayPause: false
585
+ });
586
+ expect(container.querySelector('button')).not.toBeInTheDocument();
587
+ });
588
+
589
+ it('is rendered when controls are off and the toggle is on', () => {
590
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
591
+ controls: false,
592
+ customPlayPause: true
593
+ });
594
+ expect(container.querySelector('button')).toBeInTheDocument();
595
+ });
596
+
597
+ it('toggles the underlying video on click', () => {
598
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
599
+ controls: false,
600
+ customPlayPause: true
601
+ });
602
+ const button = container.querySelector('button') as HTMLButtonElement;
603
+ const video = container.querySelector('video') as HTMLVideoElement;
604
+
605
+ button.click();
606
+ expect(video.play).toHaveBeenCalledTimes(1);
607
+
608
+ Object.defineProperty(video, 'paused', { value: false, configurable: true });
609
+ button.click();
610
+ expect(video.pause).toHaveBeenCalledTimes(1);
611
+ });
612
+
613
+ it('stacks above a sibling content overlay (z-index 2) so its clicks are not swallowed', () => {
614
+ // A title/CTA content block placed over the video is a full-size
615
+ // `position: absolute; z-index: 1` box (see pz-theme's group content
616
+ // pattern) — its transparent area still captures pointer events. Without
617
+ // an explicit, higher z-index the button was visually and functionally
618
+ // underneath it, unclickable everywhere it overlapped.
619
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
620
+ controls: false,
621
+ customPlayPause: true
622
+ });
623
+ const button = container.querySelector('button') as HTMLButtonElement;
624
+ expect(Number(button.style.zIndex)).toBeGreaterThan(1);
625
+ });
626
+
627
+ it('matches the design: a 20x20 button, not an oversized icon', () => {
628
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
629
+ controls: false,
630
+ customPlayPause: true
631
+ });
632
+ const button = container.querySelector('button') as HTMLButtonElement;
633
+ expect(button.style.width).toBe('20px');
634
+ expect(button.style.height).toBe('20px');
635
+ });
636
+
637
+ it('draws the pause icon as two 1x8 bars', () => {
638
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
639
+ controls: false,
640
+ customPlayPause: true,
641
+ autoplay: true // starts in the "playing" state, so the Pause icon renders
642
+ });
643
+ const rects = container.querySelectorAll('button svg rect');
644
+ expect(rects).toHaveLength(2);
645
+ rects.forEach(rect => {
646
+ expect(rect.getAttribute('width')).toBe('1');
647
+ expect(rect.getAttribute('height')).toBe('8');
648
+ });
649
+ });
650
+
651
+ it('sits bottom-left, inset from the left edge, on desktop', () => {
652
+ const { container } = render(
653
+ <VideoBlock
654
+ block={
655
+ {
656
+ value: 'https://cdn.example.com/clip.mp4',
657
+ properties: { controls: false, customPlayPause: true }
658
+ } as any
659
+ }
660
+ placeholderId="p1"
661
+ sectionId="s1"
662
+ isDesigner={false}
663
+ currentBreakpoint="desktop"
664
+ />
665
+ );
666
+ const button = container.querySelector('button') as HTMLButtonElement;
667
+ expect(button.style.bottom).toBe('0px');
668
+ expect(button.style.left).toBe('24px');
669
+ expect(button.style.top).toBe('');
670
+ expect(button.style.right).toBe('');
671
+ });
672
+
673
+ it('moves to the top-right on mobile instead of just rescaling the desktop inset', () => {
674
+ const { container } = render(
675
+ <VideoBlock
676
+ block={
677
+ {
678
+ value: 'https://cdn.example.com/clip.mp4',
679
+ properties: { controls: false, customPlayPause: true }
680
+ } as any
681
+ }
682
+ placeholderId="p1"
683
+ sectionId="s1"
684
+ isDesigner={false}
685
+ currentBreakpoint="mobile"
686
+ />
687
+ );
688
+ const button = container.querySelector('button') as HTMLButtonElement;
689
+ expect(button.style.top).toBe('18px');
690
+ expect(button.style.right).toBe('18px');
691
+ expect(button.style.bottom).toBe('');
692
+ expect(button.style.left).toBe('');
693
+ });
694
+ });
450
695
  });
451
696
 
452
697
  /**
@@ -1,4 +1,6 @@
1
- import React from 'react';
1
+ 'use client';
2
+
3
+ import React, { useRef, useState } from 'react';
2
4
  import { getResponsiveValue } from '../utils';
3
5
  import { BlockRendererProps } from './block-renderer-registry';
4
6
 
@@ -29,8 +31,29 @@ interface VideoProperties {
29
31
  * `…/watch?ref=clip.mp4` through as a playable file.
30
32
  */
31
33
  sourceType?: string;
34
+ /** Gated on `controls === false` in the editor — it replaces native controls, not add to them. */
35
+ customPlayPause?: unknown;
36
+ /** Off by default: mobile plays the same file as desktop unless this is on. */
37
+ useDifferentMobileVideo?: unknown;
38
+ mobileUrl?: unknown;
39
+ mobilePoster?: unknown;
32
40
  }
33
41
 
42
+ /** Sized for a 20x20 button — the Figma control, not a generic icon glyph. */
43
+ const PlayIcon = () => (
44
+ <svg width="6" height="8" viewBox="0 0 6 8" fill="none" aria-hidden="true">
45
+ <path d="M0.5 0.5L5.5 4L0.5 7.5V0.5Z" fill="currentColor" />
46
+ </svg>
47
+ );
48
+
49
+ /** Two 1x8 bars, centered in the button by its own flex layout. */
50
+ const PauseIcon = () => (
51
+ <svg width="5" height="8" viewBox="0 0 5 8" fill="none" aria-hidden="true">
52
+ <rect x="0" y="0" width="1" height="8" fill="currentColor" />
53
+ <rect x="4" y="0" width="1" height="8" fill="currentColor" />
54
+ </svg>
55
+ );
56
+
34
57
  /** Hosts, not substrings: `evil.com/?u=youtube.com/watch?v=x` is not YouTube. */
35
58
  const YOUTUBE_HOSTS = /(^|\.)(youtube\.com|youtube-nocookie\.com|youtu\.be)$/i;
36
59
 
@@ -168,11 +191,86 @@ const VideoBlock = ({
168
191
  block,
169
192
  currentBreakpoint = 'desktop'
170
193
  }: BlockRendererProps) => {
171
- const videoUrl = block.value as string;
194
+ const desktopUrl = block.value as string;
172
195
  const properties = (block.properties || {}) as VideoProperties;
173
196
 
197
+ const isMobile = currentBreakpoint === 'mobile';
198
+ const useDifferentMobileVideo = !!getResponsiveValue(
199
+ properties.useDifferentMobileVideo,
200
+ currentBreakpoint,
201
+ false
202
+ );
203
+ const mobileUrl = getResponsiveValue(
204
+ properties.mobileUrl,
205
+ currentBreakpoint,
206
+ ''
207
+ ) as string;
208
+ const videoUrl =
209
+ isMobile && useDifferentMobileVideo && mobileUrl ? mobileUrl : desktopUrl;
210
+
211
+ // `properties` handed over RAW, not resolved: `buildEmbedParams` reads
212
+ // `autoplay`/`loop`/`muted`/`controls` the same way the native `<video>`
213
+ // below deliberately still does — see the note there.
174
214
  const parsed = parseVideoSource(videoUrl, properties);
175
215
 
216
+ /**
217
+ * Read through the breakpoint, the way `aspect-ratio` already is.
218
+ *
219
+ * The editor stores every one of these per breakpoint — `{ desktop: false }`,
220
+ * not `false`. So `properties.controls !== false` was comparing an OBJECT to
221
+ * `false`, which is never equal, and controls were on for every video whatever
222
+ * the merchant chose.
223
+ *
224
+ * `poster` had the mirror of the same defect: `typeof properties.poster ===
225
+ * 'string'` is false for the per-breakpoint object the editor writes, so a
226
+ * poster a merchant typed was stored, shown in the panel and silently
227
+ * dropped. Extended here with the mobile override this ticket adds.
228
+ *
229
+ * `autoplay`, `loop`, `muted` and `playsInline` are DELIBERATELY left
230
+ * reading raw, straight off `properties`, in the JSX below. They have the
231
+ * same defect pointing the other way: their stored value is an object, an
232
+ * object is truthy, and only the bug makes them true. Reading them
233
+ * correctly here would switch autoplay and loop OFF on every composition
234
+ * already published — a behaviour change that needs its own ticket, not a
235
+ * side effect of this one.
236
+ */
237
+ const nativeControls =
238
+ getResponsiveValue(properties.controls, currentBreakpoint, true) !== false;
239
+
240
+ const desktopPoster = getResponsiveValue(
241
+ properties.poster,
242
+ currentBreakpoint,
243
+ ''
244
+ ) as string;
245
+ const mobilePoster = getResponsiveValue(
246
+ properties.mobilePoster,
247
+ currentBreakpoint,
248
+ ''
249
+ ) as string;
250
+ const posterUrl =
251
+ isMobile && useDifferentMobileVideo && mobilePoster
252
+ ? mobilePoster
253
+ : desktopPoster;
254
+
255
+ // The custom button REPLACES native controls — it only ever shows up when
256
+ // native controls are off, matching the editor's `showWhen`.
257
+ const customPlayPause =
258
+ !nativeControls &&
259
+ !!getResponsiveValue(properties.customPlayPause, currentBreakpoint, false);
260
+
261
+ const videoRef = useRef<HTMLVideoElement>(null);
262
+ const [isPlaying, setIsPlaying] = useState(properties.autoplay === true);
263
+
264
+ const togglePlayback = () => {
265
+ const el = videoRef.current;
266
+ if (!el) return;
267
+ if (el.paused) {
268
+ el.play();
269
+ } else {
270
+ el.pause();
271
+ }
272
+ };
273
+
176
274
  /**
177
275
  * The author's choice wins; a Short supplies its own when they made none.
178
276
  * With neither, the historic fixed height is kept, so every composition
@@ -190,6 +288,25 @@ const VideoBlock = ({
190
288
  ? parsed.defaultRatio
191
289
  : undefined;
192
290
 
291
+ /**
292
+ * Read the same way `image-block.tsx` reads them, so a full-bleed hero is
293
+ * built identically for a video and an image: Height 100% + Position
294
+ * Absolute + inset 0 on the block, Object Fit Cover on the player. Without
295
+ * this the wrapper could be sized and positioned to fill its Group, but the
296
+ * `<video>` inside it stayed `height: auto` — sized by its own aspect
297
+ * ratio, not by the box it was placed in.
298
+ */
299
+ const heightStyle = getResponsiveValue(
300
+ block.styles?.height,
301
+ currentBreakpoint,
302
+ 'auto'
303
+ ) as string;
304
+ const objectFit = getResponsiveValue(
305
+ block.styles?.['object-fit'],
306
+ currentBreakpoint,
307
+ 'cover'
308
+ ) as React.CSSProperties['objectFit'];
309
+
193
310
  if (!videoUrl || !parsed) {
194
311
  return (
195
312
  <div
@@ -249,70 +366,97 @@ const VideoBlock = ({
249
366
  );
250
367
  }
251
368
 
252
- /**
253
- * Read through the breakpoint, the way `aspect-ratio` above already is.
254
- *
255
- * The editor stores every one of these per breakpoint — `{ desktop: false }`,
256
- * not `false`. So `properties.controls !== false` was comparing an OBJECT to
257
- * `false`, which is never equal, and controls were on for every video whatever
258
- * the merchant chose. Nobody saw it because nothing painted a video before
259
- * hydration; with the slides now server-rendered, a cold load would paint a
260
- * full-width native control bar under the header.
261
- *
262
- * `poster` had the mirror of the same defect: `typeof properties.poster ===
263
- * 'string'` is false for the per-breakpoint object the editor writes, so a
264
- * poster a merchant typed was stored, shown in the panel and silently
265
- * dropped.
266
- *
267
- * `autoplay`, `loop` and `muted` are DELIBERATELY left reading raw. They have
268
- * the same defect pointing the other way: their stored value is an object,
269
- * an object is truthy, and only the bug makes them true. Reading them
270
- * correctly here would switch autoplay and loop OFF on every composition
271
- * already published — a behaviour change that needs its own ticket, not a
272
- * side effect of this one. `buildEmbedParams` reads all four raw for the
273
- * same reason, and is left alone with them.
274
- *
275
- * `playsInline` is a FOURTH exclusion, and named here so its absence reads
276
- * as a decision rather than an oversight. Its defect is identical to
277
- * `controls` — same shape, same direction, same element — but the
278
- * consequence is not: reading it correctly would let a merchant's stored
279
- * `false` through, and an iPhone would then take the video fullscreen on
280
- * play instead of keeping it in the page. That is a shopper-visible
281
- * playback change on every placed composition, which belongs with the
282
- * autoplay ticket. The control bar this ticket exists to remove is a
283
- * painting problem; fullscreen playback is not.
284
- */
285
- const controls =
286
- getResponsiveValue(properties.controls, currentBreakpoint, true) !== false;
287
- const poster = getResponsiveValue(
288
- properties.poster,
289
- currentBreakpoint,
290
- undefined
291
- );
292
-
293
- return (
369
+ const videoElement = (
294
370
  <video
371
+ ref={videoRef}
295
372
  src={parsed.src}
296
- controls={controls}
373
+ controls={nativeControls}
297
374
  autoPlay={properties.autoplay}
298
375
  loop={properties.loop}
299
376
  muted={properties.muted}
300
- poster={typeof poster === 'string' && poster ? poster : undefined}
377
+ poster={posterUrl || undefined}
301
378
  // Without it iOS opens the video full screen, which is never what an
302
- // inline background clip is for.
379
+ // inline background clip is for. Left reading `properties.playsInline`
380
+ // raw for the same reason autoplay/loop/muted are above: fixing it
381
+ // would let a merchant's stored `false` through and take an iPhone
382
+ // fullscreen on play, on every composition already published — a
383
+ // shopper-visible playback change that belongs with that same,
384
+ // separate decision.
303
385
  playsInline={properties.playsInline !== false}
386
+ onPlay={() => setIsPlaying(true)}
387
+ onPause={() => setIsPlaying(false)}
304
388
  style={
305
389
  ratio
306
390
  ? {
307
391
  width: '100%',
308
392
  aspectRatio: ratio,
309
- objectFit: 'cover',
393
+ objectFit,
394
+ borderRadius: 'inherit'
395
+ }
396
+ : {
397
+ width: '100%',
398
+ height: heightStyle,
399
+ objectFit,
310
400
  borderRadius: 'inherit'
311
401
  }
312
- : { width: '100%', height: 'auto', borderRadius: 'inherit' }
313
402
  }
314
403
  />
315
404
  );
405
+
406
+ if (!customPlayPause) {
407
+ return videoElement;
408
+ }
409
+
410
+ /**
411
+ * No wrapper `<div>` around the video: `[data-block-id]` (rendered by
412
+ * `WithDesignerFeatures`) is already `position: relative` and already
413
+ * carries whatever height/position the author's block styles give it — a
414
+ * full-bleed hero video is sized THERE, not by the `<video>` tag's own
415
+ * intrinsic box. A wrapper here would only get the video's natural
416
+ * (usually much shorter) height, so `bottom: 0` landed the button near the
417
+ * top of the actually-visible video and clipped most of it away.
418
+ *
419
+ * `zIndex: 2` because a sibling content-overlay block (title/CTA on top of
420
+ * the video) is a full-size `position: absolute; z-index: 1` box — its
421
+ * unfilled area is still transparent, but not click-through, so without a
422
+ * higher z-index here the overlay wins hit-testing and swallows the click
423
+ * even where the button is visually on top of it.
424
+ */
425
+ // Desktop: bottom-left, flush to the bottom edge, inset from the left.
426
+ // Mobile: top-right instead — the design moves it off the bottom-left
427
+ // corner entirely rather than just rescaling the same inset.
428
+ const buttonPosition: React.CSSProperties = isMobile
429
+ ? { top: '18px', right: '18px' }
430
+ : { bottom: 0, left: '24px' };
431
+
432
+ return (
433
+ <>
434
+ {videoElement}
435
+ <button
436
+ type="button"
437
+ onClick={togglePlayback}
438
+ aria-label={isPlaying ? 'Pause video' : 'Play video'}
439
+ aria-pressed={isPlaying}
440
+ style={{
441
+ position: 'absolute',
442
+ ...buttonPosition,
443
+ zIndex: 2,
444
+ width: '20px',
445
+ height: '20px',
446
+ display: 'flex',
447
+ alignItems: 'center',
448
+ justifyContent: 'center',
449
+ background: 'rgba(255, 255, 255, 0.9)',
450
+ border: 'none',
451
+ padding: 0,
452
+ cursor: 'pointer',
453
+ color: '#111'
454
+ }}
455
+ >
456
+ {isPlaying ? <PauseIcon /> : <PlayIcon />}
457
+ </button>
458
+ </>
459
+ );
316
460
  };
317
461
 
318
462
  export default VideoBlock;
@@ -68,7 +68,7 @@ const slotState = (slug = 'header') =>
68
68
  const renderSlot = (slug = 'header') =>
69
69
  render(
70
70
  <div data-chrome-slot={slug}>
71
- <ChromeScrollState />
71
+ <ChromeScrollState slug={slug} />
72
72
  </div>
73
73
  );
74
74
 
@@ -172,6 +172,20 @@ describe('the chrome slot scroll signal', () => {
172
172
  crossViewportEdge(false);
173
173
  expect(slotState('header')).toBe('scrolled');
174
174
  });
175
+
176
+ it('stamps only its own slot on mount, not a sibling still waiting to hydrate', () => {
177
+ // Under Suspense, a header and a footer can hydrate at different times.
178
+ // The footer's `ChromeScrollState` mounting first must not reach into a
179
+ // header slot that exists in the DOM but has not mounted its own instance
180
+ // yet — that footer-driven write is exactly what made the header's own
181
+ // (later) hydration pass find an attribute its server markup never had.
182
+ render(<div data-chrome-slot="header" />);
183
+
184
+ renderSlot('footer');
185
+
186
+ expect(slotState('footer')).toBe('top');
187
+ expect(slotState('header')).toBeNull();
188
+ });
175
189
  });
176
190
 
177
191
  describe('previewing the scroll state in the designer', () => {
@@ -127,7 +127,7 @@ export default async function ChromeSlot({
127
127
  ) : null}
128
128
  {/* Publishes `data-scroll-state` on the wrapper above, so a composition
129
129
  can style a header differently once the page is scrolled past it. */}
130
- <ChromeScrollState />
130
+ <ChromeScrollState slug={slug} />
131
131
  {pinned && <StickySlotOverflow />}
132
132
  <ThemePlaceholder slug={slug} pageContext={pageContext} />
133
133
  </div>
@@ -151,7 +151,7 @@ export default async function ChromeSlot({
151
151
  // live site keeps rendering nothing.
152
152
  <ChromeFallback slug={slug} emptyZone />
153
153
  ) : null}
154
- <ChromeScrollState />
154
+ <ChromeScrollState slug={slug} />
155
155
  {pinned && <StickySlotOverflow />}
156
156
  {/* Empty until the designer overrides this area from the editor */}
157
157
  <ThemePlaceholder slug={slug} pageContext={pageContext} />
@@ -65,6 +65,34 @@ const stampSlots = () => {
65
65
  .forEach((slot) => slot.setAttribute(SCROLL_STATE_ATTRIBUTE, state));
66
66
  };
67
67
 
68
+ /**
69
+ * Stamps one named slot rather than every slot in the document.
70
+ *
71
+ * Used only for the stamp a slot gives itself the moment it mounts (see
72
+ * `observeScrollState` below) — never for the observer's own crossing
73
+ * callback, which must keep reaching every slot so a header and a footer
74
+ * mounted together still agree.
75
+ *
76
+ * The distinction matters under Suspense: a page can hydrate its slots at
77
+ * different times (a header outside a boundary, a footer inside one that
78
+ * still awaits data). The unscoped stamp used to run for every slot the
79
+ * moment ANY one of them mounted, writing the attribute onto a footer that
80
+ * had not hydrated yet — its own hydration pass then found an attribute in
81
+ * the real DOM that the server markup it was reconciling against never had,
82
+ * which React reports as a hydration mismatch even though nothing was
83
+ * actually wrong: the footer's own effect was about to write the same value
84
+ * moments later anyway.
85
+ */
86
+ const stampSlot = (slug: string) => {
87
+ if (typeof document === 'undefined') return;
88
+
89
+ const state = pinnedState ?? observedState;
90
+ const slot = document.querySelector<HTMLElement>(
91
+ `div[data-chrome-slot="${CSS.escape(slug)}"]`
92
+ );
93
+ slot?.setAttribute(SCROLL_STATE_ATTRIBUTE, state);
94
+ };
95
+
68
96
  const createSentinel = (): HTMLElement => {
69
97
  const element = document.createElement('div');
70
98
 
@@ -116,13 +144,22 @@ const stop = () => {
116
144
  /**
117
145
  * Registers one slot with the shared observer and returns the release its
118
146
  * effect has to run.
147
+ *
148
+ * `slug` scopes the mount-time stamp to just this slot — pass it whenever the
149
+ * caller knows which slot it is (`ChromeSlot` always does). Omit it only for
150
+ * a caller with no slug to give (the public export, used outside a
151
+ * `ChromeSlot`), which falls back to stamping every slot as before.
119
152
  */
120
- export const observeScrollState = (): (() => void) => {
153
+ export const observeScrollState = (slug?: string): (() => void) => {
121
154
  registrations += 1;
122
155
  start();
123
156
  // A slot that mounted after the last crossing — a client route change, a late
124
157
  // hydration — would otherwise carry no attribute until the next one.
125
- stampSlots();
158
+ if (slug) {
159
+ stampSlot(slug);
160
+ } else {
161
+ stampSlots();
162
+ }
126
163
 
127
164
  return () => {
128
165
  registrations -= 1;
@@ -152,9 +189,14 @@ export const pinScrollState = (state: ScrollState | null): void => {
152
189
  /**
153
190
  * Mounted by every ChromeSlot. Renders nothing: the slot element it stamps is
154
191
  * the server-rendered wrapper it sits inside.
192
+ *
193
+ * `slug` is the slot's own `data-chrome-slot` value — `ChromeSlot` always
194
+ * passes it, so mounting only ever stamps this slot, never one still waiting
195
+ * to hydrate elsewhere on the page (see `stampSlot`). Left unset only for a
196
+ * caller outside a `ChromeSlot`, where every slot is stamped as before.
155
197
  */
156
- export const ChromeScrollState = () => {
157
- useEffect(() => observeScrollState(), []);
198
+ export const ChromeScrollState = ({ slug }: { slug?: string } = {}) => {
199
+ useEffect(() => observeScrollState(slug), [slug]);
158
200
 
159
201
  return null;
160
202
  };