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

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,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.118-beta.0
3
+ ## 2.0.118
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.118-beta.0
9
- - @akinon/theme-contract@2.0.118-beta.0
7
+ - a58283b: Give a phone the mobile video at the first paint: let the player fill the box the stylesheet already shaped instead of re-declaring it from a breakpoint the server cannot know, and name the single file the viewport needs on the client, so a block with a separate mobile clip no longer ships the desktop one to every phone
8
+ - @akinon/next@2.0.118
9
+ - @akinon/theme-contract@2.0.118
10
10
 
11
11
  ## 2.0.117
12
12
 
package/jest/setup.ts CHANGED
@@ -25,6 +25,17 @@ if (hasWindow) {
25
25
  window.scrollTo = (() => {}) as typeof window.scrollTo;
26
26
  }
27
27
 
28
+ // jsdom declares the media methods but implements none of them: calling one
29
+ // prints a "Not implemented" error through the virtual console, which turns any
30
+ // component that drives a player on mount into noise in an otherwise passing
31
+ // run. No test asserts on playback itself — only on which file the element was
32
+ // pointed at — so no-ops are the honest shim.
33
+ if (hasWindow && typeof HTMLMediaElement !== 'undefined') {
34
+ HTMLMediaElement.prototype.load = () => {};
35
+ HTMLMediaElement.prototype.play = () => Promise.resolve();
36
+ HTMLMediaElement.prototype.pause = () => {};
37
+ }
38
+
28
39
  // `react-dom/server` builds a MessageChannel at module scope, and jsdom has
29
40
  // none — importing it in a jsdom suite throws before a single test runs. Node's
30
41
  // own MessageChannel is not the answer: its ports keep the event loop alive, so
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.118-beta.0",
4
+ "version": "2.0.118",
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.118-beta.0",
11
+ "@akinon/next": "2.0.118",
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.118-beta.0",
16
+ "@akinon/theme-contract": "2.0.118",
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.118-beta.0",
24
+ "@akinon/next": "2.0.118",
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",
@@ -1,7 +1,36 @@
1
1
  import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
+ import { renderToStaticMarkup } from 'react-dom/server';
3
4
  import VideoBlock from '../video-block';
4
5
 
6
+ /**
7
+ * Stand the test in a viewport band.
8
+ *
9
+ * Outside the editor the VIEWPORT decides which file plays, not the breakpoint
10
+ * prop — the prop is still `'desktop'` when a phone paints for the first time,
11
+ * which is exactly what used to put the landscape clip in every phone's HTML.
12
+ * jsdom's `matchMedia` shim always answers `false`, so a storefront test has to
13
+ * say which band it is standing in.
14
+ */
15
+ const withViewport = (narrow: boolean, run: () => void) => {
16
+ const original = window.matchMedia;
17
+ window.matchMedia = ((query: string) => ({
18
+ matches: narrow && query.includes('max-width'),
19
+ media: query,
20
+ onchange: null,
21
+ addListener: () => {},
22
+ removeListener: () => {},
23
+ addEventListener: () => {},
24
+ removeEventListener: () => {},
25
+ dispatchEvent: () => false
26
+ })) as unknown as typeof window.matchMedia;
27
+ try {
28
+ run();
29
+ } finally {
30
+ window.matchMedia = original;
31
+ }
32
+ };
33
+
5
34
  const renderBlock = (value: any, properties?: any, styles?: any) =>
6
35
  render(
7
36
  <VideoBlock
@@ -440,14 +469,22 @@ describe('VideoBlock', () => {
440
469
  expect(iframe.style.height).toBe('auto');
441
470
  });
442
471
 
443
- it('shapes a direct file too', () => {
472
+ it('leaves a direct file to the box the stylesheet already shaped', () => {
473
+ // The opposite of the iframe above, and deliberately so. A file player
474
+ // sits inside `[data-block-id]`, and `generateThemeCSS` has already put
475
+ // the author's ratio THERE, per breakpoint, as a real media query — right
476
+ // at the first paint with no JavaScript. Re-resolving it here at
477
+ // `currentBreakpoint` (the literal 'desktop' on the server and through a
478
+ // phone's first render) and inlining it is what beat that media query and
479
+ // left a 9/16 box 693px tall holding a 192px player over 500px of white.
444
480
  const { container } = renderBlock(
445
481
  'https://cdn.example.com/clip.mp4',
446
482
  undefined,
447
483
  { 'aspect-ratio': { desktop: '9/16' } }
448
484
  );
449
485
  const video = container.querySelector('video') as HTMLVideoElement;
450
- expect(video.style.aspectRatio).toBe('9/16');
486
+ expect(video.style.getPropertyValue('aspect-ratio')).toBe('');
487
+ expect(video.style.height).toBe('100%');
451
488
  expect(video.style.objectFit).toBe('cover');
452
489
  });
453
490
  });
@@ -468,6 +505,18 @@ describe('VideoBlock', () => {
468
505
  expect(video.style.height).toBe('100%');
469
506
  });
470
507
 
508
+ it('fills its box unconditionally, without asking the breakpoint', () => {
509
+ // Measured in WebKit, Gecko and Blink: where nothing sizes the wrapper a
510
+ // percentage height resolves to `auto`, so an unsized block behaves
511
+ // exactly as it always did. Asking `currentBreakpoint` whether the box
512
+ // was sized would reintroduce the defect, because the answer differs
513
+ // between the server and the phone's first paint.
514
+ const { container } = renderBlock('https://cdn.example.com/clip.mp4');
515
+ const video = container.querySelector('video') as HTMLVideoElement;
516
+ expect(video.style.height).toBe('100%');
517
+ expect(video.style.getPropertyValue('aspect-ratio')).toBe('');
518
+ });
519
+
471
520
  it('defaults Object Fit to cover even without an authored aspect ratio', () => {
472
521
  const { container } = renderBlock('https://cdn.example.com/clip.mp4');
473
522
  const video = container.querySelector('video') as HTMLVideoElement;
@@ -519,36 +568,146 @@ describe('VideoBlock', () => {
519
568
  ).toBe('https://cdn.example.com/desktop.mp4');
520
569
  });
521
570
 
522
- it('plays the mobile file on mobile when useDifferentMobileVideo is on', () => {
523
- const { container } = renderAt(
524
- 'https://cdn.example.com/desktop.mp4',
525
- {
526
- useDifferentMobileVideo: true,
527
- mobileUrl: 'https://cdn.example.com/mobile.mp4'
528
- },
529
- 'mobile'
530
- );
531
- expect(
532
- (container.querySelector('video') as HTMLVideoElement).src
533
- ).toBe('https://cdn.example.com/mobile.mp4');
571
+ it('plays the mobile file on a phone when useDifferentMobileVideo is on', () => {
572
+ withViewport(true, () => {
573
+ const { container } = renderAt(
574
+ 'https://cdn.example.com/desktop.mp4',
575
+ {
576
+ useDifferentMobileVideo: true,
577
+ mobileUrl: 'https://cdn.example.com/mobile.mp4'
578
+ },
579
+ 'mobile'
580
+ );
581
+ expect(
582
+ (container.querySelector('video') as HTMLVideoElement).src
583
+ ).toBe('https://cdn.example.com/mobile.mp4');
584
+ });
534
585
  });
535
586
 
536
587
  it('plays the mobile file on a tablet too, the way the CSS already does', () => {
537
588
  // The mobile media query is max-width: 1023px, so at 800px every style
538
- // around this player is already the mobile one. Reading the source with
539
- // `=== 'mobile'` left the tablet on the landscape clip inside a layout
540
- // that had gone vertical.
541
- const { container } = renderAt(
542
- 'https://cdn.example.com/desktop.mp4',
543
- {
544
- useDifferentMobileVideo: true,
545
- mobileUrl: 'https://cdn.example.com/mobile.mp4'
546
- },
547
- 'tablet'
589
+ // around this player is already the mobile one. The condition the source
590
+ // is chosen with is that same band, so a tablet cannot drift off it.
591
+ withViewport(true, () => {
592
+ const { container } = renderAt(
593
+ 'https://cdn.example.com/desktop.mp4',
594
+ {
595
+ useDifferentMobileVideo: true,
596
+ mobileUrl: 'https://cdn.example.com/mobile.mp4'
597
+ },
598
+ 'tablet'
599
+ );
600
+ expect((container.querySelector('video') as HTMLVideoElement).src).toBe(
601
+ 'https://cdn.example.com/mobile.mp4'
602
+ );
603
+ });
604
+ });
605
+
606
+ it('keeps the desktop file on a desktop viewport', () => {
607
+ withViewport(false, () => {
608
+ const { container } = renderAt(
609
+ 'https://cdn.example.com/desktop.mp4',
610
+ {
611
+ useDifferentMobileVideo: true,
612
+ mobileUrl: 'https://cdn.example.com/mobile.mp4'
613
+ },
614
+ 'desktop'
615
+ );
616
+ expect(
617
+ (container.querySelector('video') as HTMLVideoElement).src
618
+ ).toBe('https://cdn.example.com/desktop.mp4');
619
+ });
620
+ });
621
+
622
+ it('ships no source at all from the server when the files differ', () => {
623
+ // THE DEFECT THIS EXISTS TO PIN. The server has no viewport, and the page
624
+ // is statically generated, so it cannot get one from the request either.
625
+ // Naming a file in the markup therefore names the WRONG one for every
626
+ // phone: ~16MB fetched and thrown away on this brand's homepage, plus a
627
+ // visible swap. So when the two addresses genuinely differ, the server
628
+ // ships the player empty and the client names the single file it needs.
629
+ const html = renderToStaticMarkup(
630
+ <VideoBlock
631
+ block={
632
+ {
633
+ value: 'https://cdn.example.com/desktop.mp4',
634
+ properties: {
635
+ useDifferentMobileVideo: true,
636
+ mobileUrl: 'https://cdn.example.com/mobile.mp4'
637
+ }
638
+ } as any
639
+ }
640
+ placeholderId="p1"
641
+ sectionId="s1"
642
+ isDesigner={false}
643
+ />
548
644
  );
549
- expect((container.querySelector('video') as HTMLVideoElement).src).toBe(
550
- 'https://cdn.example.com/mobile.mp4'
645
+ expect(html).toContain('<video');
646
+ expect(html).not.toContain('desktop.mp4');
647
+ expect(html).not.toContain('mobile.mp4');
648
+ });
649
+
650
+ it('still ships the source from the server for a single-file block', () => {
651
+ // The gate has to leave every composition that never authored a mobile
652
+ // override byte-identical — including its parse-time head start.
653
+ const html = renderToStaticMarkup(
654
+ <VideoBlock
655
+ block={{ value: 'https://cdn.example.com/only.mp4' } as any}
656
+ placeholderId="p1"
657
+ sectionId="s1"
658
+ isDesigner={false}
659
+ />
551
660
  );
661
+ expect(html).toContain('https://cdn.example.com/only.mp4');
662
+ });
663
+
664
+ it('reads the override however the editor happened to store it', () => {
665
+ // The live composition that reported this bug stores BOTH keys under
666
+ // `desktop` only — `{"useDifferentMobileVideo":{"desktop":true}}` and
667
+ // `{"mobileUrl":{"desktop":"..."}}`. A gate resolved at the breakpoint
668
+ // would answer differently on the server and on the phone and the player
669
+ // would not hydrate, so the gate ignores the breakpoint entirely.
670
+ withViewport(true, () => {
671
+ const { container } = renderAt(
672
+ 'https://cdn.example.com/desktop.mp4',
673
+ {
674
+ useDifferentMobileVideo: { desktop: true },
675
+ mobileUrl: { desktop: 'https://cdn.example.com/mobile.mp4' }
676
+ },
677
+ 'mobile'
678
+ );
679
+ expect(
680
+ (container.querySelector('video') as HTMLVideoElement).src
681
+ ).toBe('https://cdn.example.com/mobile.mp4');
682
+ });
683
+ });
684
+
685
+ it('follows the editor device instead of the viewport inside the canvas', () => {
686
+ // The canvas draws a phone preview inside a desktop-wide iframe, so the
687
+ // viewport is the wrong thing to ask there. It must also keep working in
688
+ // BROWSE MODE, where the `isDesigner` a block receives is already false
689
+ // while the editor is still attached — hence the window flag rather than
690
+ // the prop.
691
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
692
+ true;
693
+ try {
694
+ withViewport(false, () => {
695
+ const { container } = renderAt(
696
+ 'https://cdn.example.com/desktop.mp4',
697
+ {
698
+ useDifferentMobileVideo: true,
699
+ mobileUrl: 'https://cdn.example.com/mobile.mp4'
700
+ },
701
+ 'mobile'
702
+ );
703
+ expect(
704
+ (container.querySelector('video') as HTMLVideoElement).src
705
+ ).toBe('https://cdn.example.com/mobile.mp4');
706
+ });
707
+ } finally {
708
+ delete (window as { __themeEditorDesigner?: boolean })
709
+ .__themeEditorDesigner;
710
+ }
552
711
  });
553
712
 
554
713
  it('takes the mobile poster on a tablet for the same reason', () => {
@@ -1,7 +1,12 @@
1
1
  'use client';
2
2
 
3
- import React, { useRef, useState } from 'react';
4
- import { getResponsiveValue, isNarrowBreakpoint } from '../utils';
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import {
5
+ BREAKPOINTS,
6
+ PHONE_AND_TABLET_MEDIA_CONDITION,
7
+ getResponsiveValue,
8
+ isNarrowBreakpoint
9
+ } from '../utils';
5
10
  import { BlockRendererProps } from './block-renderer-registry';
6
11
 
7
12
  /**
@@ -181,6 +186,36 @@ export const parseVideoSource = (
181
186
  return null;
182
187
  };
183
188
 
189
+ /**
190
+ * Is this per-breakpoint value filled in at ANY breakpoint?
191
+ *
192
+ * Deliberately breakpoint-INDEPENDENT, and that is the whole point of it.
193
+ * `currentBreakpoint` is the literal `'desktop'` on the server and stays
194
+ * `'desktop'` through a phone's FIRST client render, so a gate that read it
195
+ * would answer one thing in the server HTML and another a paint later — the
196
+ * markup would not hydrate, and the element would be rebuilt rather than
197
+ * adopted. This question has one answer at every width.
198
+ */
199
+ const setAtAnyBreakpoint = (value: unknown): boolean => {
200
+ if (value === null || value === undefined || value === '') return false;
201
+ if (typeof value !== 'object') return Boolean(value);
202
+ if (Array.isArray(value)) return false;
203
+ const perBreakpoint = value as Record<string, unknown>;
204
+ return BREAKPOINTS.some((breakpoint) => Boolean(perBreakpoint[breakpoint]));
205
+ };
206
+
207
+ /** The first address a per-breakpoint value carries, asked the same way. */
208
+ const firstUrlAtAnyBreakpoint = (value: unknown): string => {
209
+ if (typeof value === 'string') return value;
210
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
211
+ const perBreakpoint = value as Record<string, unknown>;
212
+ for (const breakpoint of BREAKPOINTS) {
213
+ const candidate = perBreakpoint[breakpoint];
214
+ if (typeof candidate === 'string' && candidate !== '') return candidate;
215
+ }
216
+ return '';
217
+ };
218
+
184
219
  /**
185
220
  * A video: a YouTube or Vimeo embed, or a file the browser plays itself.
186
221
  *
@@ -248,6 +283,21 @@ const VideoBlock = ({
248
283
 
249
284
  const parsed = parseVideoSource(videoUrl, { ...properties, sourceType });
250
285
 
286
+ /**
287
+ * Does this block really ship two DIFFERENT files?
288
+ *
289
+ * Asked without the breakpoint, for the reason `setAtAnyBreakpoint` records:
290
+ * the answer has to be identical on the server and in the phone's first
291
+ * render or the player would not hydrate. When it is yes, the server ships
292
+ * the element with no source at all and the effect below names the one file
293
+ * this viewport needs — see it for why the server cannot decide this.
294
+ */
295
+ const mobileUrlAnywhere = firstUrlAtAnyBreakpoint(properties.mobileUrl);
296
+ const hasDistinctMobileVideo =
297
+ setAtAnyBreakpoint(properties.useDifferentMobileVideo) &&
298
+ !!mobileUrlAnywhere &&
299
+ mobileUrlAnywhere !== desktopUrl;
300
+
251
301
  /**
252
302
  * Read through the breakpoint, the way `aspect-ratio` already is.
253
303
  *
@@ -306,6 +356,77 @@ const VideoBlock = ({
306
356
  }
307
357
  };
308
358
 
359
+ /**
360
+ * THE FILE, NAMED BY THE SIDE THAT KNOWS THE VIEWPORT.
361
+ *
362
+ * `videoUrl` above is chosen from `currentBreakpoint`, and on the storefront
363
+ * that value is a lie for the first paint: `theme-placeholder-wrapper.tsx`
364
+ * seeds it with the literal `'desktop'` and corrects it only in a post-mount
365
+ * effect. So the HTML every phone received named the DESKTOP clip; the phone
366
+ * began downloading the landscape film — 15.7MB of it on this brand's
367
+ * homepage — and threw it away a paint later when the portrait one the
368
+ * merchant uploaded replaced it.
369
+ *
370
+ * The server cannot answer this by looking. It has no viewport, and the page
371
+ * this runs on is statically generated with `revalidate`, so reading the
372
+ * request headers would not vary the cache — it would only take the route out
373
+ * of static generation and still answer "not mobile" for everyone.
374
+ *
375
+ * `<source media>` would let the browser choose during parsing, earlier than
376
+ * this. Measured on WebKit, Gecko and Blink it works and the skipped
377
+ * candidate is never requested — but a shopper's browser version is not
378
+ * something this package can audit, and where the attribute is ignored the
379
+ * cost lands as a whole wasted film on the phone, which is the connection
380
+ * that can least afford it. So the choice is made here instead: one code
381
+ * path, one download, on every browser.
382
+ */
383
+ useEffect(() => {
384
+ if (!hasDistinctMobileVideo) return;
385
+
386
+ const el = videoRef.current;
387
+ if (!el) return;
388
+
389
+ /*
390
+ * Inside the canvas the breakpoint is FORCED by the editor rather than read
391
+ * off the viewport, and that has to keep working in browse mode — where the
392
+ * `isDesigner` a block receives is already false while the editor is still
393
+ * attached. `__themeEditorDesigner` is the flag that answers "is the editor
394
+ * attached" and survives that toggle; see browse-mode.ts.
395
+ */
396
+ const editorAttached =
397
+ typeof window !== 'undefined' &&
398
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner ===
399
+ true;
400
+
401
+ /*
402
+ * The breakpoint prop is the fallback, not the answer: it is still
403
+ * `'desktop'` when this effect first runs on a phone (a child's effect
404
+ * commits before the placeholder's own breakpoint effect above it), which
405
+ * is the whole defect. It is only reached if a browser somehow has no
406
+ * `matchMedia` — better a stale first guess that a later render corrects
407
+ * than a TypeError that takes the player out entirely.
408
+ */
409
+ const viewportIsNarrow =
410
+ typeof window.matchMedia === 'function'
411
+ ? window.matchMedia(PHONE_AND_TABLET_MEDIA_CONDITION).matches
412
+ : isNarrowBreakpoint(currentBreakpoint);
413
+
414
+ const wantsMobile = editorAttached
415
+ ? isNarrowBreakpoint(currentBreakpoint)
416
+ : viewportIsNarrow;
417
+
418
+ const next = wantsMobile ? mobileUrlAnywhere : desktopUrl;
419
+ if (!next || el.getAttribute('src') === next) return;
420
+
421
+ el.setAttribute('src', next);
422
+ el.load();
423
+ }, [
424
+ hasDistinctMobileVideo,
425
+ currentBreakpoint,
426
+ mobileUrlAnywhere,
427
+ desktopUrl
428
+ ]);
429
+
309
430
  /**
310
431
  * The author's choice wins; a Short supplies its own when they made none.
311
432
  * With neither, the historic fixed height is kept, so every composition
@@ -331,11 +452,6 @@ const VideoBlock = ({
331
452
  * `<video>` inside it stayed `height: auto` — sized by its own aspect
332
453
  * ratio, not by the box it was placed in.
333
454
  */
334
- const heightStyle = getResponsiveValue(
335
- block.styles?.height,
336
- currentBreakpoint,
337
- 'auto'
338
- ) as string;
339
455
  const objectFit = getResponsiveValue(
340
456
  block.styles?.['object-fit'],
341
457
  currentBreakpoint,
@@ -404,7 +520,7 @@ const VideoBlock = ({
404
520
  const videoElement = (
405
521
  <video
406
522
  ref={videoRef}
407
- src={parsed.src}
523
+ src={hasDistinctMobileVideo ? undefined : parsed.src}
408
524
  controls={nativeControls}
409
525
  autoPlay={properties.autoplay}
410
526
  loop={properties.loop}
@@ -420,21 +536,35 @@ const VideoBlock = ({
420
536
  playsInline={properties.playsInline !== false}
421
537
  onPlay={() => setIsPlaying(true)}
422
538
  onPause={() => setIsPlaying(false)}
423
- style={
424
- ratio
425
- ? {
426
- width: '100%',
427
- aspectRatio: ratio,
428
- objectFit,
429
- borderRadius: 'inherit'
430
- }
431
- : {
432
- width: '100%',
433
- height: heightStyle,
434
- objectFit,
435
- borderRadius: 'inherit'
436
- }
437
- }
539
+ /*
540
+ * THE PLAYER FILLS ITS BOX; IT DOES NOT RE-DECLARE IT.
541
+ *
542
+ * `video` is not an inner-root type, so `generateThemeCSS` already writes
543
+ * the author's `aspect-ratio` and `height` onto the block's WRAPPER, and
544
+ * on the storefront it writes them as a real cascade — the box is the
545
+ * right shape at the first paint with no JavaScript at all. Re-resolving
546
+ * those same keys at `currentBreakpoint` and inlining them here is what
547
+ * broke it: the stored ratio is often mobile-only, `currentBreakpoint` is
548
+ * `'desktop'` for the server and the first client render, so the player
549
+ * shipped `height: auto` — and an inline style outranks the media query
550
+ * that had just sized the wrapper correctly. A 9/16 box 693px tall got a
551
+ * 192px player and 500px of white.
552
+ *
553
+ * `height: 100%` is unconditional on purpose. Measured in WebKit, Gecko
554
+ * and Blink: where nothing sizes the wrapper a percentage height resolves
555
+ * to `auto`, byte-identical to the old behaviour, so an unsized block is
556
+ * untouched. Where the author DID size it the player now fills it — which
557
+ * also ends the overflow a fixed pixel height used to produce.
558
+ *
559
+ * `aspect-ratio` stays inline for the `<iframe>` branch above, because a
560
+ * Short's ratio is derived from the URL and the stylesheet cannot know it.
561
+ */
562
+ style={{
563
+ width: '100%',
564
+ height: '100%',
565
+ objectFit,
566
+ borderRadius: 'inherit'
567
+ }}
438
568
  />
439
569
  );
440
570