@akinon/pz-theme 2.0.127-beta.0 → 2.0.127

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.
@@ -5,7 +5,8 @@ import {
5
5
  BREAKPOINTS,
6
6
  PHONE_AND_TABLET_MEDIA_CONDITION,
7
7
  getResponsiveValue,
8
- isNarrowBreakpoint
8
+ isNarrowBreakpoint,
9
+ partAttrs
9
10
  } from '../utils';
10
11
  import { BlockRendererProps } from './block-renderer-registry';
11
12
 
@@ -38,27 +39,132 @@ interface VideoProperties {
38
39
  sourceType?: string;
39
40
  /** Gated on `controls === false` in the editor — it replaces native controls, not add to them. */
40
41
  customPlayPause?: unknown;
42
+ /** Second control beside play/pause. Off by default: an ambient clip is silent furniture. */
43
+ soundToggle?: unknown;
44
+ /** One glyph per state the control can be in. Empty means "keep ours". */
45
+ playIcon?: unknown;
46
+ pauseIcon?: unknown;
47
+ soundOnIcon?: unknown;
48
+ soundOffIcon?: unknown;
49
+ /** On by default — see the observer effect for what "ambient" means here. */
50
+ playWhenVisible?: unknown;
51
+ /** `auto` | `metadata` | `none`, straight through to the element's attribute. */
52
+ preload?: unknown;
41
53
  /** Off by default: mobile plays the same file as desktop unless this is on. */
42
54
  useDifferentMobileVideo?: unknown;
43
55
  mobileUrl?: unknown;
44
56
  mobilePoster?: unknown;
45
57
  }
46
58
 
47
- /** Sized for a 20x20 button — the Figma control, not a generic icon glyph. */
59
+ /**
60
+ * An OUTLINED triangle, not a solid one: one path that fills the triangle and
61
+ * then cuts a smaller one out of it, so the glyph reads as a hairline at 8px
62
+ * the way the pause bars beside it do. A solid wedge at this size turns into
63
+ * an ink blob next to two 1px rules.
64
+ */
48
65
  const PlayIcon = () => (
49
- <svg width="6" height="8" viewBox="0 0 6 8" fill="none" aria-hidden="true">
50
- <path d="M0.5 0.5L5.5 4L0.5 7.5V0.5Z" fill="currentColor" />
66
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
67
+ <path fill="currentColor" d="M7 4 1 8V0zM2 6.13 5.197 4 2 1.868z" />
51
68
  </svg>
52
69
  );
53
70
 
54
- /** Two 1x8 bars, centered in the button by its own flex layout. */
71
+ /** Two 1x8 bars at the outer edges of the 8x8 box, to the play glyph's weight. */
55
72
  const PauseIcon = () => (
56
- <svg width="5" height="8" viewBox="0 0 5 8" fill="none" aria-hidden="true">
57
- <rect x="0" y="0" width="1" height="8" fill="currentColor" />
58
- <rect x="4" y="0" width="1" height="8" fill="currentColor" />
73
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true">
74
+ <path d="M1.5 8h-1V0h1zm6 0h-1V0h1z" />
75
+ </svg>
76
+ );
77
+
78
+ /**
79
+ * AN AUTHORED GLYPH, PAINTED BY WHATEVER THE PANEL PUT ON THE BUTTON.
80
+ *
81
+ * A mask rather than an `<img>`, and rather than the SVG-rewriting the icon
82
+ * block and the slider both do. Those two read a colour off the block's own
83
+ * styles and bake it into the SVG string before handing it over — which cannot
84
+ * work here, because this button's colour is a PART style: it is emitted as
85
+ * CSS (`[data-block-id=…] [data-part="control-button"]{color:…}`) and the
86
+ * renderer never sees the value. A mask filled with `currentColor` inherits
87
+ * whatever that rule resolved to, so an authored glyph answers Text Color
88
+ * exactly the way the four built-in ones do.
89
+ *
90
+ * The trade is that a multi-colour icon arrives monochrome. For a 20px control
91
+ * that is the right way round — the alternative is a glyph that ignores the
92
+ * brand colour the panel just set, on the one element whose whole job is to be
93
+ * legible against the film behind it.
94
+ */
95
+ const AuthoredGlyph = ({ src }: { src: string }) => (
96
+ <span
97
+ aria-hidden="true"
98
+ style={{
99
+ display: 'block',
100
+ width: '100%',
101
+ height: '100%',
102
+ backgroundColor: 'currentColor',
103
+ WebkitMaskImage: `url("${src}")`,
104
+ maskImage: `url("${src}")`,
105
+ WebkitMaskRepeat: 'no-repeat',
106
+ maskRepeat: 'no-repeat',
107
+ WebkitMaskPosition: 'center',
108
+ maskPosition: 'center',
109
+ // `contain`, not `cover`: a glyph that overflows its 20px button reads as
110
+ // a smear, and an author's icon is rarely the same aspect as ours.
111
+ WebkitMaskSize: 'contain',
112
+ maskSize: 'contain'
113
+ }}
114
+ />
115
+ );
116
+
117
+ /** Speaker with the sound arc struck through — the clip is silent right now. */
118
+ const SoundOffIcon = () => (
119
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
120
+ <path fill="currentColor" d="M0 3h1.4L3.4 1v6L1.4 5H0z" />
121
+ <path
122
+ stroke="currentColor"
123
+ strokeWidth="0.8"
124
+ strokeLinecap="round"
125
+ d="m5.2 2.8 2 2.4m0-2.4-2 2.4"
126
+ />
127
+ </svg>
128
+ );
129
+
130
+ /** The same speaker, arc intact. */
131
+ const SoundOnIcon = () => (
132
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
133
+ <path fill="currentColor" d="M0 3h1.4L3.4 1v6L1.4 5H0z" />
134
+ <path
135
+ stroke="currentColor"
136
+ strokeWidth="0.8"
137
+ strokeLinecap="round"
138
+ d="M5.2 2.5a2.1 2.1 0 0 1 0 3M6.6 1.6a3.6 3.6 0 0 1 0 4.8"
139
+ />
59
140
  </svg>
60
141
  );
61
142
 
143
+ /**
144
+ * The share of the player that has to be on screen before it plays, and below
145
+ * which it pauses again.
146
+ *
147
+ * Measured against the reference storefront rather than picked: its own
148
+ * observer is built with `threshold: 0.1`, and stepping a clip out of view
149
+ * there confirms the edge — still playing at 15% visible, paused at 8%.
150
+ */
151
+ const VISIBILITY_PLAY_THRESHOLD = 0.1;
152
+
153
+ /** The inset the control sits at, from the two corners it is anchored to. */
154
+ const CONTROL_INSET_DESKTOP = '24px';
155
+ const CONTROL_INSET_NARROW = '18px';
156
+
157
+ /**
158
+ * Asked at the moment it matters rather than held in state: the answer is only
159
+ * ever consulted inside an effect, and a shopper who changes this setting
160
+ * mid-visit is not a case worth a subscription. Same spelling — spaces and all
161
+ * — as `collapsible-block.tsx`, which is the other runtime reader.
162
+ */
163
+ const prefersReducedMotion = () =>
164
+ typeof window !== 'undefined' &&
165
+ typeof window.matchMedia === 'function' &&
166
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
167
+
62
168
  /** Hosts, not substrings: `evil.com/?u=youtube.com/watch?v=x` is not YouTube. */
63
169
  const YOUTUBE_HOSTS = /(^|\.)(youtube\.com|youtube-nocookie\.com|youtu\.be)$/i;
64
170
 
@@ -220,10 +326,12 @@ const firstUrlAtAnyBreakpoint = (value: unknown): string => {
220
326
  * A video: a YouTube or Vimeo embed, or a file the browser plays itself.
221
327
  *
222
328
  * Properties: url (on `block.value`), controls, autoplay, loop, muted,
223
- * playsInline, poster, sourceType. Style: `aspect-ratio`.
329
+ * playsInline, poster, sourceType, preload, playWhenVisible, customPlayPause,
330
+ * soundToggle. Style: `aspect-ratio`.
224
331
  */
225
332
  const VideoBlock = ({
226
333
  block,
334
+ isDesigner = false,
227
335
  currentBreakpoint = 'desktop'
228
336
  }: BlockRendererProps) => {
229
337
  const desktopUrl = block.value as string;
@@ -343,19 +451,265 @@ const VideoBlock = ({
343
451
  !nativeControls &&
344
452
  !!getResponsiveValue(properties.customPlayPause, currentBreakpoint, false);
345
453
 
454
+ /**
455
+ * The four glyphs, each falling back to the built-in one.
456
+ *
457
+ * Read per breakpoint like every other authored value, so a brand can carry
458
+ * a different mark on the phone — the control is 18px from the edge there
459
+ * rather than 24, and a busier glyph stops reading at that size.
460
+ */
461
+ const authoredGlyph = (key: keyof VideoProperties) =>
462
+ (getResponsiveValue(properties[key], currentBreakpoint, '') as string) || '';
463
+
464
+ const glyphFor = (key: keyof VideoProperties, fallback: React.ReactNode) => {
465
+ const src = authoredGlyph(key);
466
+ return src ? <AuthoredGlyph src={src} /> : fallback;
467
+ };
468
+
469
+ /** The mute button rides with the play/pause one; it cannot appear alone. */
470
+ const showSoundToggle =
471
+ customPlayPause &&
472
+ !!getResponsiveValue(properties.soundToggle, currentBreakpoint, false);
473
+
474
+ /**
475
+ * DOES THIS CLIP PLAY BY ITSELF?
476
+ *
477
+ * `Boolean(properties.autoplay)`, raw and unresolved, on purpose: it is the
478
+ * SAME expression the `autoPlay` attribute below is written with, and the
479
+ * point is that the two can never disagree. The note further down records
480
+ * why that attribute still reads raw — the editor stores `{ desktop: false }`,
481
+ * an object is truthy, and reading it correctly would switch autoplay off on
482
+ * every composition already published. Until that is done under its own
483
+ * ticket, "would this have played on its own?" has exactly one answer, and
484
+ * asking it any other way here would arm the observer on clips the page does
485
+ * not currently start, or fail to arm it on the ones it does.
486
+ */
487
+ const wantsAutoplay = Boolean(properties.autoplay);
488
+
489
+ const playWhenVisible =
490
+ getResponsiveValue(properties.playWhenVisible, currentBreakpoint, true) !==
491
+ false;
492
+
493
+ /**
494
+ * "Ambient": a clip the page starts on the shopper's behalf, with no controls
495
+ * of its own — the campaign film behind a headline. Only those are driven by
496
+ * the viewport.
497
+ *
498
+ * `!nativeControls` is the load-bearing half of that sentence, and leaving it
499
+ * out was a real regression rather than a nicety. `wantsAutoplay` is truthy
500
+ * for EVERY block the editor has ever written (see above), so without it the
501
+ * observer armed on ordinary videos that show the browser's own control bar:
502
+ * a shopper who pressed pause at 0:30, scrolled the player off screen and
503
+ * scrolled back would find it playing again, because this observer
504
+ * deliberately does not remember a manual pause. A player with controls is a
505
+ * deliberate watch; it is left entirely alone.
506
+ *
507
+ * `nativeControls` IS read per breakpoint, so this gate is exact even while
508
+ * the autoplay read next to it is not.
509
+ */
510
+ const ambientPlayback =
511
+ wantsAutoplay && !nativeControls && playWhenVisible && !isDesigner;
512
+
346
513
  const videoRef = useRef<HTMLVideoElement>(null);
347
- const [isPlaying, setIsPlaying] = useState(properties.autoplay === true);
348
514
 
349
- const togglePlayback = () => {
515
+ /**
516
+ * What the GLYPH says, which is not the same as what the author asked for.
517
+ *
518
+ * An ambient clip starts at `false` because nothing has started it yet: it
519
+ * carries no autoplay attribute, and the observer only presses play once the
520
+ * clip is on screen — the `play` event flips this within a frame of that.
521
+ * Seeding it `true` there meant the control drew the pause bars and announced
522
+ * "Pause video" over a clip standing still, and stayed that way for good
523
+ * wherever playback never began at all: a reduced-motion shopper, an iPhone
524
+ * in Low Power Mode refusing even a muted start, a 404 on the file. None of
525
+ * those fire a `pause` event to correct it, because the element was already
526
+ * paused.
527
+ *
528
+ * Everything else keeps the attribute and really does start on its own, so
529
+ * `wantsAutoplay` is the honest first answer there.
530
+ */
531
+ const [isPlaying, setIsPlaying] = useState(
532
+ ambientPlayback ? false : wantsAutoplay
533
+ );
534
+
535
+ /**
536
+ * Whether the observer last saw this player on screen.
537
+ *
538
+ * A ref rather than state because the only reader is the src-swapping effect
539
+ * below, and re-rendering the player every time a clip scrolls past would be
540
+ * a lot of work to change nothing. See that effect for why it has to ask.
541
+ */
542
+ const isInViewRef = useRef(false);
543
+
544
+ /**
545
+ * Starts false on the server AND on the first client render, which is what
546
+ * keeps `preload` hydratable: both sides ship `none`, and only the observer
547
+ * moves it. See the preload note on the element itself.
548
+ */
549
+ const [hasEnteredView, setHasEnteredView] = useState(false);
550
+
551
+ const mutedProperty = Boolean(properties.muted);
552
+ const [isMuted, setIsMuted] = useState(mutedProperty);
553
+
554
+ /*
555
+ * The author flipping Muted has to win over whatever the shopper's sound
556
+ * button last did. Worth knowing what this can and cannot reach: the stored
557
+ * value is per-breakpoint for anything the editor wrote, and `Boolean()` of
558
+ * an object is a constant, so for those blocks this never fires — the same
559
+ * raw read the autoplay note above explains, and it moves with that ticket.
560
+ * It does work for the flat booleans templates and HTML import write.
561
+ */
562
+ useEffect(() => {
563
+ setIsMuted(mutedProperty);
564
+ }, [mutedProperty]);
565
+
566
+ /**
567
+ * `play()` returns a promise that REJECTS when the browser declines — an
568
+ * unmuted clip with no user gesture, or one interrupted by a `pause()` a
569
+ * frame later. Every call site swallows it: a refused play is a poster
570
+ * sitting still, not an error worth an unhandled rejection in the console.
571
+ */
572
+ const requestPlay = (el: HTMLVideoElement) => {
573
+ const started = el.play();
574
+ if (started && typeof started.catch === 'function') {
575
+ started.catch(() => undefined);
576
+ }
577
+ };
578
+
579
+ /**
580
+ * THE CONTROL IS NOT PART OF THE BLOCK'S ACTION.
581
+ *
582
+ * `WithDesignerFeatures` puts the block's own click handler on the
583
+ * `[data-block-id]` wrapper whenever an Action is set, and `video` is not in
584
+ * `NATIVELY_INTERACTIVE_BLOCK_TYPES`, so it gets one. A campaign tile built
585
+ * as a Video block with Action = Link therefore navigated away the moment a
586
+ * shopper tapped pause: the video paused and the click carried on up. Note
587
+ * that `pointerEvents: 'auto'` on the buttons governs hit-testing only — it
588
+ * has never had anything to say about propagation.
589
+ *
590
+ * NOT in the designer, though, and that exception is the whole of why this
591
+ * is a function rather than one call. The canvas resolves which PART was
592
+ * clicked from a single bubble handler on that same wrapper
593
+ * (`use-designer-features`), so stopping the click unconditionally meant a
594
+ * click on the button selected NOTHING — not the Control Button part, not
595
+ * even the Video block. `hotspot-block.tsx` returns early on `isDesigner`
596
+ * for exactly this reason, and this follows it.
597
+ */
598
+ const stopBubbling = (event: React.MouseEvent) => {
599
+ if (isDesigner) return;
600
+ event.stopPropagation();
601
+ };
602
+
603
+ const togglePlayback = (event: React.MouseEvent) => {
604
+ stopBubbling(event);
350
605
  const el = videoRef.current;
351
606
  if (!el) return;
352
607
  if (el.paused) {
353
- el.play();
608
+ requestPlay(el);
354
609
  } else {
355
610
  el.pause();
356
611
  }
357
612
  };
358
613
 
614
+ const toggleSound = (event: React.MouseEvent) => {
615
+ stopBubbling(event);
616
+ const el = videoRef.current;
617
+ const next = !isMuted;
618
+ setIsMuted(next);
619
+ // Written straight to the element as well: React sets `muted` as a DOM
620
+ // PROPERTY and never emits it as an attribute, so a re-render alone is not
621
+ // guaranteed to reach an element the browser is already playing.
622
+ if (el) el.muted = next;
623
+ };
624
+
625
+ /**
626
+ * PLAY WHILE IT IS ON SCREEN; STOP WHEN IT LEAVES.
627
+ *
628
+ * An `autoPlay` attribute starts a clip the moment it is parsed, wherever it
629
+ * sits on the page — so a homepage with three of them decodes three films at
630
+ * once, two of which nobody has scrolled to. This binds playback to the
631
+ * viewport instead, which is both the behaviour the reference storefront has
632
+ * and the reason a long page stays cheap.
633
+ *
634
+ * Three things are deliberate:
635
+ *
636
+ * - `pause()` on the way out does NOT touch `currentTime`. Scrolling back
637
+ * resumes where the clip stopped, measured as the reference behaviour.
638
+ * - A manual pause is NOT remembered across a scroll-out. The observer is
639
+ * authoritative; the button is an override that lasts while the clip is on
640
+ * screen. Also measured — pausing by hand, scrolling away and back plays
641
+ * again there too.
642
+ * - It stands down in the designer (`isDesigner`, folded into
643
+ * `ambientPlayback`), the way every other observer- or timer-driven
644
+ * affordance in this package does. The canvas keeps whatever the `autoPlay`
645
+ * attribute already gave it, so authoring is unchanged.
646
+ *
647
+ * Both escape hatches below flip `hasEnteredView` before returning. The
648
+ * element is holding `preload="none"` waiting for this effect, so bailing out
649
+ * without doing so would leave a clip that never loads at all.
650
+ */
651
+ useEffect(() => {
652
+ if (!ambientPlayback) return undefined;
653
+
654
+ const el = videoRef.current;
655
+ if (!el) return undefined;
656
+
657
+ if (typeof IntersectionObserver === 'undefined') {
658
+ // Nothing else is going to start this clip — an ambient one ships no
659
+ // `autoplay` attribute precisely so that this effect owns the decision —
660
+ // so the fallback has to both release the file and press play itself.
661
+ setHasEnteredView(true);
662
+ requestPlay(el);
663
+ return undefined;
664
+ }
665
+
666
+ if (prefersReducedMotion()) {
667
+ // Release the file so the shopper can still press play; just never start
668
+ // it for them. `pause()` covers the case where something else already
669
+ // had it running before this effect re-ran.
670
+ setHasEnteredView(true);
671
+ el.pause();
672
+ return undefined;
673
+ }
674
+
675
+ const observer = new IntersectionObserver(
676
+ (entries) => {
677
+ // The LAST entry, not the first: a burst delivered after a fast scroll
678
+ // describes where the element ended up, and acting on the head of it
679
+ // would play a clip that has already gone.
680
+ const entry = entries[entries.length - 1];
681
+ if (!entry) return;
682
+
683
+ isInViewRef.current = entry.isIntersecting;
684
+
685
+ if (entry.isIntersecting) {
686
+ setHasEnteredView(true);
687
+ requestPlay(el);
688
+ } else {
689
+ el.pause();
690
+ }
691
+ },
692
+ { threshold: VISIBILITY_PLAY_THRESHOLD }
693
+ );
694
+
695
+ observer.observe(el);
696
+ return () => {
697
+ observer.disconnect();
698
+ isInViewRef.current = false;
699
+ };
700
+ /*
701
+ * `parsed?.kind` is in the list because this effect reads `videoRef.current`
702
+ * and gives up silently when it is null — and which ELEMENT gets rendered
703
+ * is breakpoint-dependent. An author who pastes a YouTube link and uploads
704
+ * an mp4 for phones gets the `<iframe>` branch on the first client render
705
+ * (currentBreakpoint is the literal 'desktop' until a post-mount effect
706
+ * says otherwise); the `<video>` only appears on the render after that.
707
+ * Without this the effect never runs again, and that clip spends the rest
708
+ * of the page's life at `preload="none"` with nothing watching the
709
+ * viewport — the feature simply absent, with nothing to show for it.
710
+ */
711
+ }, [ambientPlayback, parsed?.kind]);
712
+
359
713
  /**
360
714
  * THE FILE, NAMED BY THE SIDE THAT KNOWS THE VIEWPORT.
361
715
  *
@@ -420,11 +774,30 @@ const VideoBlock = ({
420
774
 
421
775
  el.setAttribute('src', next);
422
776
  el.load();
777
+
778
+ /*
779
+ * `load()` RESETS THE PLAYER, SO WHOEVER WAS PLAYING HAS TO BE RESTARTED.
780
+ *
781
+ * The observer above only speaks when the clip CROSSES its threshold. It
782
+ * has nothing to say about a clip that was already on screen and stayed
783
+ * there — which is exactly the case here: this effect runs on mount before
784
+ * the first intersection is delivered, and again whenever a resize or a
785
+ * rotation crosses 1024px. Both leave a freshly reset, paused element
786
+ * sitting in full view with no further intersection coming to start it.
787
+ *
788
+ * The `autoPlay` attribute is not a substitute. Its "can autoplay" flag is
789
+ * cleared the first time the element is paused or played, so a clip that
790
+ * has already been on screen once does not get a second free start from it.
791
+ */
792
+ if (ambientPlayback && isInViewRef.current) {
793
+ requestPlay(el);
794
+ }
423
795
  }, [
424
796
  hasDistinctMobileVideo,
425
797
  currentBreakpoint,
426
798
  mobileUrlAnywhere,
427
- desktopUrl
799
+ desktopUrl,
800
+ ambientPlayback
428
801
  ]);
429
802
 
430
803
  /**
@@ -458,6 +831,28 @@ const VideoBlock = ({
458
831
  'cover'
459
832
  ) as React.CSSProperties['objectFit'];
460
833
 
834
+ /**
835
+ * HOW MUCH OF THE FILE TO FETCH BEFORE ANYONE ASKS FOR IT.
836
+ *
837
+ * The element carried no `preload` at all until now, so every browser fell
838
+ * back to its own default — in practice `metadata`, and on some builds
839
+ * `auto` — and a clip five screens down the homepage started downloading
840
+ * with the page. `metadata` is the new floor: enough for the box and the
841
+ * first frame, a few kilobytes rather than the film.
842
+ *
843
+ * On top of that, an ambient clip ships `none` until the observer above says
844
+ * it has reached the viewport. That is the whole of the lazy story, and it
845
+ * costs the poster nothing: `poster` is an image the element paints whatever
846
+ * `preload` says, so the box is never empty while the file is being skipped.
847
+ */
848
+ const authoredPreload = getResponsiveValue(
849
+ properties.preload,
850
+ currentBreakpoint,
851
+ 'metadata'
852
+ ) as string;
853
+ const preload =
854
+ ambientPlayback && !hasEnteredView ? 'none' : authoredPreload;
855
+
461
856
  if (!videoUrl || !parsed) {
462
857
  return (
463
858
  <div
@@ -522,9 +917,28 @@ const VideoBlock = ({
522
917
  ref={videoRef}
523
918
  src={hasDistinctMobileVideo ? undefined : parsed.src}
524
919
  controls={nativeControls}
525
- autoPlay={properties.autoplay}
920
+ /*
921
+ * ONE STARTER, NOT TWO.
922
+ *
923
+ * An ambient clip ships NO autoplay attribute: the observer above starts
924
+ * it, and this is what makes `preload="none"` beside it mean anything.
925
+ * The HTML spec lets the autoplay attribute override preload, and both
926
+ * Blink and Gecko take it — an element carrying both downloaded the whole
927
+ * film at parse time and deferred only the playing of it, which is the
928
+ * opposite of the saving this is for. It also ended the fight between two
929
+ * drivers: `load()` in the src-swapping effect below re-arms the
930
+ * attribute's can-autoplay flag, which used to restart a clip the
931
+ * reduced-motion branch had just stood down.
932
+ *
933
+ * The cost is that an ambient clip does not start with JavaScript off. It
934
+ * could never have stopped without it either, and the poster still paints.
935
+ */
936
+ autoPlay={ambientPlayback ? undefined : properties.autoplay}
526
937
  loop={properties.loop}
527
- muted={properties.muted}
938
+ // Seeded from `Boolean(properties.muted)` — the same truthiness the raw
939
+ // read had — and thereafter owned by the sound button, when there is one.
940
+ muted={isMuted}
941
+ preload={preload}
528
942
  poster={posterUrl || undefined}
529
943
  // Without it iOS opens the video full screen, which is never what an
530
944
  // inline background clip is for. Left reading `properties.playsInline`
@@ -573,13 +987,34 @@ const VideoBlock = ({
573
987
  }
574
988
 
575
989
  /**
576
- * No wrapper `<div>` around the video: `[data-block-id]` (rendered by
577
- * `WithDesignerFeatures`) is already `position: relative` and already
578
- * carries whatever height/position the author's block styles give it — a
579
- * full-bleed hero video is sized THERE, not by the `<video>` tag's own
580
- * intrinsic box. A wrapper here would only get the video's natural
581
- * (usually much shorter) height, so `bottom: 0` landed the button near the
582
- * top of the actually-visible video and clipped most of it away.
990
+ * THE CONTROL LAYER — BOTTOM RIGHT, AND STILL THERE ON A TALL CLIP.
991
+ *
992
+ * Still no wrapper around the VIDEO: `[data-block-id]` (rendered by
993
+ * `WithDesignerFeatures`) is already `position: relative` and already carries
994
+ * whatever height/position the author's block styles give it — a full-bleed
995
+ * hero is sized THERE, not by the `<video>` tag's own intrinsic box, and
996
+ * wrapping the player would hand the controls the player's natural (usually
997
+ * much shorter) height instead. What is added here is a SIBLING overlay,
998
+ * `inset: 0` against that same wrapper, so it inherits the authored box
999
+ * exactly the way the single absolute button used to.
1000
+ *
1001
+ * Three nested boxes, because one absolutely positioned button cannot do it:
1002
+ *
1003
+ * overlay inset 0, flex, justify-content flex-end -> the right edge
1004
+ * column flex column, justify-content flex-end -> the bottom edge
1005
+ * rail position: sticky, bottom: <inset> -> stays reachable
1006
+ *
1007
+ * The sticky rail is what a 2:3 portrait clip needs. At 749px wide that box
1008
+ * is 1123px tall — taller than most laptop viewports — so a control pinned
1009
+ * to the bottom of the block is off screen for the whole time the clip is
1010
+ * being watched. Sticky keeps it the same inset above the viewport floor
1011
+ * until the real bottom comes into range, then parks it there. Where an
1012
+ * ancestor's `overflow: hidden` defeats sticky, it degrades to its static
1013
+ * position, which is the bottom-right corner it was going to sit in anyway.
1014
+ *
1015
+ * Both outer boxes are `pointer-events: none` — the overlay covers the whole
1016
+ * player, and the shopper must still be able to click a link sitting under
1017
+ * it. Only the buttons take pointer events back.
583
1018
  *
584
1019
  * `zIndex: 2` because a sibling content-overlay block (title/CTA on top of
585
1020
  * the video) is a full-size `position: absolute; z-index: 1` box — its
@@ -587,39 +1022,88 @@ const VideoBlock = ({
587
1022
  * higher z-index here the overlay wins hit-testing and swallows the click
588
1023
  * even where the button is visually on top of it.
589
1024
  */
590
- // Desktop: bottom-left, flush to the bottom edge, inset from the left.
591
- // Mobile: top-right instead — the design moves it off the bottom-left
592
- // corner entirely rather than just rescaling the same inset.
593
- const buttonPosition: React.CSSProperties = isMobile
594
- ? { top: '18px', right: '18px' }
595
- : { bottom: 0, left: '24px' };
1025
+ const controlInset = isMobile ? CONTROL_INSET_NARROW : CONTROL_INSET_DESKTOP;
1026
+
1027
+ const controlButtonStyle: React.CSSProperties = {
1028
+ width: '20px',
1029
+ height: '20px',
1030
+ display: 'flex',
1031
+ alignItems: 'center',
1032
+ justifyContent: 'center',
1033
+ background: '#fff',
1034
+ border: 'none',
1035
+ borderRadius: 0,
1036
+ padding: 0,
1037
+ margin: 0,
1038
+ cursor: 'pointer',
1039
+ color: '#000',
1040
+ pointerEvents: 'auto'
1041
+ };
596
1042
 
597
1043
  return (
598
1044
  <>
599
1045
  {videoElement}
600
- <button
601
- type="button"
602
- onClick={togglePlayback}
603
- aria-label={isPlaying ? 'Pause video' : 'Play video'}
604
- aria-pressed={isPlaying}
1046
+ <div
1047
+ {...partAttrs('controls')}
605
1048
  style={{
606
1049
  position: 'absolute',
607
- ...buttonPosition,
1050
+ inset: 0,
608
1051
  zIndex: 2,
609
- width: '20px',
610
- height: '20px',
611
1052
  display: 'flex',
612
- alignItems: 'center',
613
- justifyContent: 'center',
614
- background: 'rgba(255, 255, 255, 0.9)',
615
- border: 'none',
616
- padding: 0,
617
- cursor: 'pointer',
618
- color: '#111'
1053
+ justifyContent: 'flex-end',
1054
+ padding: controlInset,
1055
+ pointerEvents: 'none',
1056
+ borderRadius: 'inherit'
619
1057
  }}
620
1058
  >
621
- {isPlaying ? <PauseIcon /> : <PlayIcon />}
622
- </button>
1059
+ <div
1060
+ style={{
1061
+ display: 'flex',
1062
+ flexDirection: 'column',
1063
+ justifyContent: 'flex-end',
1064
+ pointerEvents: 'none'
1065
+ }}
1066
+ >
1067
+ <div
1068
+ {...partAttrs('control-rail')}
1069
+ style={{
1070
+ position: 'sticky',
1071
+ bottom: controlInset,
1072
+ display: 'flex',
1073
+ alignItems: 'center',
1074
+ gap: '8px',
1075
+ pointerEvents: 'none'
1076
+ }}
1077
+ >
1078
+ {showSoundToggle && (
1079
+ <button
1080
+ type="button"
1081
+ {...partAttrs('control-button')}
1082
+ onClick={toggleSound}
1083
+ aria-label={isMuted ? 'Unmute video' : 'Mute video'}
1084
+ aria-pressed={!isMuted}
1085
+ style={controlButtonStyle}
1086
+ >
1087
+ {isMuted
1088
+ ? glyphFor('soundOffIcon', <SoundOffIcon />)
1089
+ : glyphFor('soundOnIcon', <SoundOnIcon />)}
1090
+ </button>
1091
+ )}
1092
+ <button
1093
+ type="button"
1094
+ {...partAttrs('control-button')}
1095
+ onClick={togglePlayback}
1096
+ aria-label={isPlaying ? 'Pause video' : 'Play video'}
1097
+ aria-pressed={isPlaying}
1098
+ style={controlButtonStyle}
1099
+ >
1100
+ {isPlaying
1101
+ ? glyphFor('pauseIcon', <PauseIcon />)
1102
+ : glyphFor('playIcon', <PlayIcon />)}
1103
+ </button>
1104
+ </div>
1105
+ </div>
1106
+ </div>
623
1107
  </>
624
1108
  );
625
1109
  };