@akinon/pz-theme 2.0.147-rc.0 → 2.0.147

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,40 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.147-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
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 [b1111d7b1]
34
- - Updated dependencies [1595d458e]
35
- - Updated dependencies [dca128112]
36
- - @akinon/next@2.0.147-rc.0
37
- - @akinon/theme-contract@2.0.147-rc.0
3
+ ## 2.0.147
4
+
5
+ ### Patch Changes
6
+
7
+ - 0ff4179: ZERO-5177: place the video block's play/pause and sound buttons by Button Position and two distances, and paint the default inset per breakpoint from the first frame
8
+ - @akinon/next@2.0.147
9
+ - @akinon/theme-contract@2.0.147
38
10
 
39
11
  ## 2.0.146
40
12
 
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.147-rc.0",
4
+ "version": "2.0.147",
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.147-rc.0",
11
+ "@akinon/next": "2.0.147",
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.147-rc.0",
16
+ "@akinon/theme-contract": "2.0.147",
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.147-rc.0",
24
+ "@akinon/next": "2.0.147",
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",
@@ -377,7 +377,8 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
377
377
  // rename would silently break.
378
378
  expect(frames()[0].actionCapabilities).toEqual([
379
379
  'add-to-cart-after-add',
380
- 'localized-video'
380
+ 'localized-video',
381
+ 'video-control-position'
381
382
  ]);
382
383
  });
383
384
 
@@ -418,8 +419,8 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
418
419
  // panel back to "On Click" halfway through a session.
419
420
  expect(postMessage).toHaveBeenCalledTimes(2);
420
421
  expect(frames().map((frame) => frame.actionCapabilities)).toEqual([
421
- ['add-to-cart-after-add', 'localized-video'],
422
- ['add-to-cart-after-add', 'localized-video']
422
+ ['add-to-cart-after-add', 'localized-video', 'video-control-position'],
423
+ ['add-to-cart-after-add', 'localized-video', 'video-control-position']
423
424
  ]);
424
425
 
425
426
  // The reporting list rides every frame for the same reason: the editor
@@ -16,6 +16,12 @@
16
16
  * "On Click", to trim the option list, and to stop
17
17
  * offering Focus and Pressed root chips on a
18
18
  * wrapper that is no longer a control.
19
+ * - `video-control-position` the Video block places its play/pause and sound
20
+ * buttons by `controlPosition`, `controlOffsetX`
21
+ * and `controlOffsetY`. The editor offers those
22
+ * controls only to a storefront that says so —
23
+ * on an older theme they would save and move
24
+ * nothing.
19
25
  *
20
26
  * Names rather than a version number, for the reason the state pins are: the
21
27
  * editor must not have to know which release added what. A build that does not
@@ -31,9 +37,13 @@
31
37
  * the placeholder registry announces this, and it must not have to pull a
32
38
  * client component in to say one list of strings.
33
39
  */
34
- export type ActionCapability = 'add-to-cart-after-add' | 'localized-video';
40
+ export type ActionCapability =
41
+ | 'add-to-cart-after-add'
42
+ | 'localized-video'
43
+ | 'video-control-position';
35
44
 
36
45
  export const ACTION_CAPABILITIES: readonly ActionCapability[] = [
37
46
  'add-to-cart-after-add',
38
- 'localized-video'
47
+ 'localized-video',
48
+ 'video-control-position'
39
49
  ];
@@ -91,6 +91,7 @@ import ProductGalleryBlock from '../product-gallery-block';
91
91
  import ProductGridBlock from '../product-grid-block';
92
92
  import ProductRecommendationsBlock from '../product-recommendations-block';
93
93
  import StoreLocatorBlock from '../store-locator-block';
94
+ import VideoBlock from '../video-block';
94
95
  import { ThemePageContextProvider } from '../../theme-page-context';
95
96
 
96
97
  const block = (
@@ -138,6 +139,20 @@ const iconGroup = (id: string) =>
138
139
  styles: { size: { desktop: '24' } }
139
140
  });
140
141
 
142
+ /** A file-backed video drawing its own control layer, which is what writes the placement sheet. */
143
+ const videoBlock = (id: string, controlPosition?: unknown) =>
144
+ block(
145
+ id,
146
+ 'video',
147
+ {
148
+ controls: { desktop: false },
149
+ customPlayPause: { desktop: true },
150
+ ...(controlPosition ? { controlPosition } : {})
151
+ },
152
+ [],
153
+ { value: 'https://cdn.example.com/campaign.mp4' }
154
+ );
155
+
141
156
  const withPage = (value: Record<string, unknown> | null, element: React.ReactElement) => (
142
157
  <ThemePageContextProvider value={value}>{element}</ThemePageContextProvider>
143
158
  );
@@ -290,6 +305,20 @@ const cases: Array<[string, () => React.ReactElement, string[]]> = [
290
305
  [
291
306
  '[data-store-locator-id="nearby-stores"]{grid-template-columns:repeat(2,minmax(0,1fr));}'
292
307
  ]
308
+ ],
309
+ [
310
+ 'video control placement',
311
+ () => (
312
+ <VideoBlock
313
+ {...base}
314
+ block={videoBlock('campaign-film', { desktop: 'top-left' })}
315
+ />
316
+ ),
317
+ [
318
+ ':where([data-pz-video-controls="campaign-film"]){padding:24px 24px!important;justify-content:flex-start!important}',
319
+ ':where([data-pz-video-controls="campaign-film"]>div){justify-content:flex-start!important}',
320
+ '@media (max-width:1023px){:where([data-pz-video-controls="campaign-film"]){padding:18px 18px!important}'
321
+ ]
293
322
  ]
294
323
  ];
295
324
 
@@ -336,7 +365,11 @@ describe('an id that tries to close the tag stays inside the sheet', () => {
336
365
  )
337
366
  ],
338
367
  ['icon button', () => <IconButtonBlock {...base} block={iconButton(HOSTILE_ID)} />],
339
- ['icon group', () => <IconGroupBlock {...base} block={iconGroup(HOSTILE_ID)} />]
368
+ ['icon group', () => <IconGroupBlock {...base} block={iconGroup(HOSTILE_ID)} />],
369
+ [
370
+ 'video controls',
371
+ () => <VideoBlock {...base} block={videoBlock(HOSTILE_ID)} />
372
+ ]
340
373
  ])('%s', (_name, element) => {
341
374
  // The collapsible's `<noscript>` copy is a separate raw-text context this
342
375
  // change did not touch, so only the sheets outside it are asserted.
@@ -1,7 +1,10 @@
1
1
  import React from 'react';
2
2
  import { act, render, screen } from '@testing-library/react';
3
- import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { renderToStaticMarkup, renderToString } from 'react-dom/server';
4
+ import { hydrateRoot } from 'react-dom/client';
4
5
  import VideoBlock from '../video-block';
6
+ import { __resetBrowseModeForTests } from '../../browse-mode';
7
+ import { registerCustomBreakpoints } from '../../utils/breakpoints';
5
8
 
6
9
  /**
7
10
  * Stand the test in a viewport band.
@@ -1601,3 +1604,696 @@ describe('properties the editor stores per breakpoint', () => {
1601
1604
  expect(video.loop).toBe(true);
1602
1605
  });
1603
1606
  });
1607
+
1608
+ /**
1609
+ * WHERE THE CONTROL LAYER SITS (ZERO-5177).
1610
+ *
1611
+ * Button Position and the two distances are per-breakpoint properties, and
1612
+ * they reach the page as a stylesheet rather than as inline values resolved at
1613
+ * `currentBreakpoint` — that prop is `'desktop'` on the server and through a
1614
+ * phone's first render, so an inline answer paints every phone with the
1615
+ * desktop corner first. What is pinned here is the sheet itself: where it
1616
+ * sits, what each band writes, and what it may never take away.
1617
+ */
1618
+ describe('where the control layer sits', () => {
1619
+ const FILE = 'https://cdn.example.com/campaign.mp4';
1620
+ const customControls = {
1621
+ controls: { desktop: false },
1622
+ customPlayPause: { desktop: true }
1623
+ };
1624
+
1625
+ const renderControls = (
1626
+ properties: Record<string, unknown> = {},
1627
+ options: {
1628
+ id?: string | null;
1629
+ isDesigner?: boolean;
1630
+ currentBreakpoint?: string;
1631
+ value?: string;
1632
+ styles?: Record<string, unknown>;
1633
+ } = {}
1634
+ ) =>
1635
+ render(
1636
+ <VideoBlock
1637
+ block={
1638
+ {
1639
+ id: options.id === null ? undefined : options.id ?? 'campaign-film',
1640
+ value: options.value ?? FILE,
1641
+ properties: { ...customControls, ...properties },
1642
+ styles: options.styles
1643
+ } as any
1644
+ }
1645
+ placeholderId="p1"
1646
+ sectionId="s1"
1647
+ isDesigner={options.isDesigner ?? false}
1648
+ currentBreakpoint={options.currentBreakpoint ?? 'desktop'}
1649
+ />
1650
+ );
1651
+
1652
+ const sheetOf = (container: HTMLElement) =>
1653
+ container.querySelector('style')?.innerHTML ?? '';
1654
+
1655
+ const scope = '[data-pz-video-controls="campaign-film"]';
1656
+ const overlayRule = (declarations: string) =>
1657
+ `:where(${scope}){${declarations}}`;
1658
+ const columnRule = (declarations: string) =>
1659
+ `:where(${scope}>div){${declarations}}`;
1660
+ const railRule = (declarations: string) =>
1661
+ `:where(${scope} [data-part="control-rail"]){${declarations}}`;
1662
+ const MOBILE = '@media (max-width:1023px)';
1663
+ const TABLET = '@media (min-width:768px) and (max-width:1023px)';
1664
+
1665
+ describe('the sheet and the element it styles', () => {
1666
+ it('sits right before the overlay, which stays the last child', () => {
1667
+ // The generator's root hover rule reaches the buttons through
1668
+ // `> div:last-child button`; a sheet appended after the overlay would
1669
+ // have taken that away from every video with a hover colour.
1670
+ const { container } = renderControls();
1671
+ const { overlay } = controlBoxes(container);
1672
+ const sheet = container.querySelector('style') as HTMLStyleElement;
1673
+
1674
+ expect(sheet).not.toBeNull();
1675
+ expect(sheet.nextElementSibling).toBe(overlay);
1676
+ expect(overlay.parentElement?.lastElementChild).toBe(overlay);
1677
+ });
1678
+
1679
+ it('names the overlay by the block id the selectors use', () => {
1680
+ const { container } = renderControls();
1681
+ expect(
1682
+ controlBoxes(container).overlay.getAttribute('data-pz-video-controls')
1683
+ ).toBe('campaign-film');
1684
+ expect(sheetOf(container)).toContain(scope);
1685
+ });
1686
+
1687
+ it('keeps the raw id on the element and escapes it only in the selector', () => {
1688
+ const { container } = renderControls({}, { id: 'film"x' });
1689
+
1690
+ expect(
1691
+ controlBoxes(container).overlay.getAttribute('data-pz-video-controls')
1692
+ ).toBe('film"x');
1693
+ expect(sheetOf(container)).toContain(
1694
+ '[data-pz-video-controls="film\\"x"]'
1695
+ );
1696
+ });
1697
+
1698
+ it('is only there when the custom control is', () => {
1699
+ expect(
1700
+ renderControls({ controls: { desktop: true } }).container.querySelector(
1701
+ 'style'
1702
+ )
1703
+ ).toBeNull();
1704
+ expect(
1705
+ renderControls({
1706
+ customPlayPause: { desktop: false }
1707
+ }).container.querySelector('style')
1708
+ ).toBeNull();
1709
+ // An embed never draws the custom control at all.
1710
+ expect(
1711
+ renderControls(
1712
+ {},
1713
+ { value: 'https://www.youtube.com/watch?v=abc123' }
1714
+ ).container.querySelector('style')
1715
+ ).toBeNull();
1716
+ });
1717
+
1718
+ it('outranks only the inline styles and unflagged rules, never an authored !important', () => {
1719
+ // Specificity 0 (`:where`) with `!important`: beats the inline defaults,
1720
+ // loses to every authored `!important` with any specificity — the
1721
+ // Control Overlay part keys and a brand's own override alike.
1722
+ const sheet = sheetOf(
1723
+ renderControls({
1724
+ controlPosition: { desktop: 'top-left', mobile: 'middle-center' },
1725
+ controlOffsetX: { desktop: 40 }
1726
+ }).container
1727
+ );
1728
+ const rules = sheet
1729
+ .replace(/@media[^{]*\{/g, '')
1730
+ .split('}')
1731
+ .filter((rule) => rule.includes('{'));
1732
+
1733
+ expect(rules.length).toBeGreaterThan(0);
1734
+ rules.forEach((rule) => {
1735
+ const [selector, body] = rule.split('{');
1736
+ expect(selector.startsWith(':where(')).toBe(true);
1737
+ body.split(';').forEach((declaration) => {
1738
+ expect(declaration.endsWith('!important')).toBe(true);
1739
+ });
1740
+ });
1741
+ });
1742
+
1743
+ it('leaves the inline styles exactly as they were', () => {
1744
+ const { overlay, column, rail } = controlBoxes(
1745
+ renderControls({
1746
+ controlPosition: { desktop: 'top-left' },
1747
+ controlOffsetY: { desktop: 60 }
1748
+ }).container
1749
+ );
1750
+
1751
+ expect(overlay.style.justifyContent).toBe('flex-end');
1752
+ expect(overlay.style.padding).toBe('24px');
1753
+ expect(column.style.justifyContent).toBe('flex-end');
1754
+ expect(rail.style.position).toBe('sticky');
1755
+ expect(rail.style.bottom).toBe('24px');
1756
+ });
1757
+ });
1758
+
1759
+ describe('with nothing authored', () => {
1760
+ it('writes the inset per band, so a phone paints 18px from the first frame', () => {
1761
+ const sheet = sheetOf(renderControls().container);
1762
+
1763
+ expect(sheet).toBe(
1764
+ overlayRule('padding:24px 24px!important') +
1765
+ railRule('bottom:24px!important') +
1766
+ `${MOBILE}{` +
1767
+ overlayRule('padding:18px 18px!important') +
1768
+ railRule('bottom:18px!important') +
1769
+ '}'
1770
+ );
1771
+ });
1772
+
1773
+ it('writes no corner and no direction of its own', () => {
1774
+ // Nothing to move, so nothing that could take a merchant's Custom CSS
1775
+ // `flex-direction` (which wins today: the rail inlines none) away.
1776
+ const sheet = sheetOf(renderControls().container);
1777
+
1778
+ expect(sheet).not.toContain('justify-content');
1779
+ expect(sheet).not.toContain('flex-direction');
1780
+ });
1781
+
1782
+ it('reaches the server HTML, not only the client', () => {
1783
+ const html = renderToStaticMarkup(
1784
+ <VideoBlock
1785
+ block={
1786
+ {
1787
+ id: 'campaign-film',
1788
+ value: FILE,
1789
+ properties: customControls
1790
+ } as any
1791
+ }
1792
+ placeholderId="p1"
1793
+ sectionId="s1"
1794
+ isDesigner={false}
1795
+ />
1796
+ );
1797
+
1798
+ expect(html).toContain(
1799
+ `${MOBILE}{${overlayRule('padding:18px 18px!important')}`
1800
+ );
1801
+ });
1802
+ });
1803
+
1804
+ describe('Button Position', () => {
1805
+ it.each([
1806
+ ['top-left', 'flex-start', 'flex-start', true],
1807
+ ['top-center', 'center', 'flex-start', false],
1808
+ ['top-right', 'flex-end', 'flex-start', false],
1809
+ ['middle-left', 'flex-start', 'center', true],
1810
+ ['middle-center', 'center', 'center', false],
1811
+ ['middle-right', 'flex-end', 'center', false],
1812
+ ['bottom-left', 'flex-start', 'flex-end', true],
1813
+ ['bottom-center', 'center', 'flex-end', false],
1814
+ ['bottom-right', 'flex-end', 'flex-end', false]
1815
+ ])('places %s', (position, justify, align, mirrored) => {
1816
+ const sheet = sheetOf(
1817
+ renderControls({ controlPosition: { desktop: position } }).container
1818
+ );
1819
+ const desktop = sheet.split('@media')[0];
1820
+
1821
+ expect(desktop).toContain(
1822
+ overlayRule(
1823
+ `padding:24px 24px!important;justify-content:${justify}!important`
1824
+ )
1825
+ );
1826
+ expect(desktop).toContain(
1827
+ columnRule(`justify-content:${align}!important`)
1828
+ );
1829
+ if (mirrored) {
1830
+ // play/pause keeps the outer corner on either side; reading-flow keeps
1831
+ // the Tab order with what the eye sees.
1832
+ expect(desktop).toContain(
1833
+ railRule(
1834
+ 'bottom:24px!important;flex-direction:row-reverse!important;reading-flow:flex-visual!important'
1835
+ )
1836
+ );
1837
+ } else {
1838
+ expect(sheet).not.toContain('flex-direction');
1839
+ }
1840
+ });
1841
+
1842
+ it('moves the buttons on a phone without touching desktop', () => {
1843
+ const sheet = sheetOf(
1844
+ renderControls({
1845
+ controlPosition: { desktop: 'top-left', mobile: 'bottom-center' }
1846
+ }).container
1847
+ );
1848
+ const [desktop, mobile] = sheet.split(MOBILE);
1849
+
1850
+ expect(desktop).toContain('justify-content:flex-start!important');
1851
+ expect(mobile).toContain(
1852
+ overlayRule(
1853
+ 'padding:18px 18px!important;justify-content:center!important'
1854
+ )
1855
+ );
1856
+ expect(mobile).toContain(
1857
+ columnRule('justify-content:flex-end!important')
1858
+ );
1859
+ // The desktop mirror is undone where the phone is not on the left.
1860
+ expect(mobile).toContain(
1861
+ 'flex-direction:row!important;reading-flow:normal!important'
1862
+ );
1863
+ });
1864
+
1865
+ it('writes a band only where it differs from the one it inherits', () => {
1866
+ const sheet = sheetOf(
1867
+ renderControls({ controlPosition: { desktop: 'top-right' } }).container
1868
+ );
1869
+ const mobile = sheet.split(MOBILE)[1];
1870
+
1871
+ // Mobile inherits the corner; only its own inset is written.
1872
+ expect(mobile).not.toContain('justify-content');
1873
+ expect(sheet).not.toContain(TABLET);
1874
+ });
1875
+
1876
+ it('gives a tablet the phone answer unless it has its own', () => {
1877
+ const inherited = sheetOf(
1878
+ renderControls({ controlPosition: { mobile: 'top-left' } }).container
1879
+ );
1880
+ expect(inherited).not.toContain(TABLET);
1881
+
1882
+ const own = sheetOf(
1883
+ renderControls({
1884
+ controlPosition: { mobile: 'top-left', tablet: 'top-right' }
1885
+ }).container
1886
+ );
1887
+ // The order is the answer: both bands are specificity 0 and !important,
1888
+ // so a tablet only gets its own value because its query comes AFTER the
1889
+ // phone's (which also matches 768-1023px).
1890
+ expect(own).toBe(
1891
+ overlayRule(
1892
+ 'padding:24px 24px!important;justify-content:flex-end!important'
1893
+ ) +
1894
+ columnRule('justify-content:flex-end!important') +
1895
+ railRule('bottom:24px!important') +
1896
+ `${MOBILE}{` +
1897
+ overlayRule(
1898
+ 'padding:18px 18px!important;justify-content:flex-start!important'
1899
+ ) +
1900
+ columnRule('justify-content:flex-start!important') +
1901
+ railRule(
1902
+ 'bottom:18px!important;flex-direction:row-reverse!important;reading-flow:flex-visual!important'
1903
+ ) +
1904
+ '}' +
1905
+ `${TABLET}{` +
1906
+ overlayRule('justify-content:flex-end!important') +
1907
+ railRule(
1908
+ 'flex-direction:row!important;reading-flow:normal!important'
1909
+ ) +
1910
+ '}'
1911
+ );
1912
+ });
1913
+
1914
+ it('reads a flat value, the shape templates write', () => {
1915
+ expect(
1916
+ sheetOf(renderControls({ controlPosition: 'middle-center' }).container)
1917
+ ).toContain(columnRule('justify-content:center!important'));
1918
+ });
1919
+
1920
+ it('treats anything it does not know as the corner it always had', () => {
1921
+ const sheet = sheetOf(
1922
+ renderControls({ controlPosition: { desktop: 'sideways-up' } })
1923
+ .container
1924
+ );
1925
+ expect(sheet).toContain('justify-content:flex-end!important');
1926
+ expect(sheet).toContain(columnRule('justify-content:flex-end!important'));
1927
+ });
1928
+ });
1929
+
1930
+ describe('the two distances', () => {
1931
+ it('sets each axis on its own', () => {
1932
+ const sheet = sheetOf(
1933
+ renderControls({
1934
+ controlOffsetX: { desktop: 40 },
1935
+ controlOffsetY: { desktop: 12 }
1936
+ }).container
1937
+ );
1938
+
1939
+ expect(sheet).toContain(overlayRule('padding:12px 40px!important'));
1940
+ // The same vertical distance is kept from the screen's foot while the
1941
+ // rail follows a tall clip — one value for resting and travelling.
1942
+ expect(sheet).toContain(railRule('bottom:12px!important'));
1943
+ });
1944
+
1945
+ it('reads numeric strings, the shape a template, an import or a hand-edited theme carries', () => {
1946
+ expect(
1947
+ sheetOf(renderControls({ controlOffsetX: { desktop: '40' } }).container)
1948
+ ).toContain(overlayRule('padding:24px 40px!important'));
1949
+ });
1950
+
1951
+ it('keeps an authored 0', () => {
1952
+ expect(
1953
+ sheetOf(renderControls({ controlOffsetY: { desktop: 0 } }).container)
1954
+ ).toContain(overlayRule('padding:0px 24px!important'));
1955
+ });
1956
+
1957
+ it('clamps to the range the editor offers', () => {
1958
+ expect(
1959
+ sheetOf(renderControls({ controlOffsetX: { desktop: 999 } }).container)
1960
+ ).toContain('padding:24px 200px!important');
1961
+ expect(
1962
+ sheetOf(renderControls({ controlOffsetX: { desktop: -8 } }).container)
1963
+ ).toContain('padding:24px 0px!important');
1964
+ });
1965
+
1966
+ it('falls back to the inset for a value it cannot read', () => {
1967
+ expect(
1968
+ sheetOf(
1969
+ renderControls({ controlOffsetX: { desktop: 'wide' } }).container
1970
+ )
1971
+ ).toContain(overlayRule('padding:24px 24px!important'));
1972
+ });
1973
+
1974
+ it('keeps the desktop inset when only the phone was set', () => {
1975
+ // The authoring order that broke a breakpoint-object default in the
1976
+ // panel: phone first, then desktop looked at.
1977
+ const sheet = sheetOf(
1978
+ renderControls({ controlOffsetX: { mobile: '10' } }).container
1979
+ );
1980
+ const [desktop, mobile] = sheet.split(MOBILE);
1981
+
1982
+ expect(desktop).toContain(overlayRule('padding:24px 24px!important'));
1983
+ expect(mobile).toContain(overlayRule('padding:18px 10px!important'));
1984
+ });
1985
+
1986
+ it('lets a narrower band inherit a desktop distance, the way every style does', () => {
1987
+ const sheet = sheetOf(
1988
+ renderControls({ controlOffsetX: { desktop: 40 } }).container
1989
+ );
1990
+ expect(sheet.split(MOBILE)[1]).toContain(
1991
+ overlayRule('padding:18px 40px!important')
1992
+ );
1993
+ });
1994
+ });
1995
+
1996
+ describe('in the editor', () => {
1997
+ it('writes the one band the editor is forcing, without a query', () => {
1998
+ const sheet = sheetOf(
1999
+ renderControls(
2000
+ { controlPosition: { desktop: 'top-left', mobile: 'middle-center' } },
2001
+ { isDesigner: true, currentBreakpoint: 'mobile' }
2002
+ ).container
2003
+ );
2004
+
2005
+ expect(sheet).not.toContain('@media');
2006
+ expect(sheet).toContain(
2007
+ overlayRule(
2008
+ 'padding:18px 18px!important;justify-content:center!important'
2009
+ )
2010
+ );
2011
+ expect(sheet).toContain(columnRule('justify-content:center!important'));
2012
+ });
2013
+
2014
+ it('answers for the storefront the same way whatever breakpoint the prop says', () => {
2015
+ // The server and a phone's first render both say 'desktop'; a sheet that
2016
+ // listened to the prop would not hydrate.
2017
+ const atDesktop = sheetOf(
2018
+ renderControls(
2019
+ { controlPosition: { mobile: 'top-left' } },
2020
+ { currentBreakpoint: 'desktop' }
2021
+ ).container
2022
+ );
2023
+ const atMobile = sheetOf(
2024
+ renderControls(
2025
+ { controlPosition: { mobile: 'top-left' } },
2026
+ { currentBreakpoint: 'mobile' }
2027
+ ).container
2028
+ );
2029
+
2030
+ expect(atMobile).toBe(atDesktop);
2031
+ });
2032
+ });
2033
+
2034
+ describe('mirroring follows where the rail actually ends up', () => {
2035
+ it('follows a Control Overlay Horizontal Corner over the preset', () => {
2036
+ // The part key outranks the preset's justify-content, so a preset Left
2037
+ // under a stored Right puts the rail on the right — and mirroring by the
2038
+ // preset would hand the sound button the corner.
2039
+ const rightAfterAll = sheetOf(
2040
+ renderControls(
2041
+ { controlPosition: { desktop: 'bottom-left' } },
2042
+ { styles: { 'controls/justify-content': { desktop: 'flex-end' } } }
2043
+ ).container
2044
+ );
2045
+ expect(rightAfterAll).not.toContain('row-reverse');
2046
+
2047
+ const leftAfterAll = sheetOf(
2048
+ renderControls(
2049
+ { controlPosition: { desktop: 'bottom-right' } },
2050
+ { styles: { 'controls/justify-content': { desktop: 'flex-start' } } }
2051
+ ).container
2052
+ );
2053
+ expect(leftAfterAll).toContain('flex-direction:row-reverse!important');
2054
+ });
2055
+
2056
+ it('leaves a block with nothing but a legacy Left in the order it renders today', () => {
2057
+ const sheet = sheetOf(
2058
+ renderControls(
2059
+ {},
2060
+ { styles: { 'controls/justify-content': { desktop: 'flex-start' } } }
2061
+ ).container
2062
+ );
2063
+ expect(sheet).not.toContain('flex-direction');
2064
+ });
2065
+ });
2066
+
2067
+ describe('in browse mode', () => {
2068
+ afterEach(() => __resetBrowseModeForTests());
2069
+
2070
+ it('still writes the one band the editor forces', () => {
2071
+ // Blocks get isDesigner false while browsing, but the canvas is still
2072
+ // the editor's zoomed frame, where WebKit answers a real media query by
2073
+ // the zoomed width — a phone answer on a desktop canvas.
2074
+ (
2075
+ window as { __themeEditorBrowseMode?: boolean }
2076
+ ).__themeEditorBrowseMode = true;
2077
+
2078
+ const sheet = sheetOf(
2079
+ renderControls({}, { isDesigner: false, currentBreakpoint: 'desktop' })
2080
+ .container
2081
+ );
2082
+
2083
+ expect(sheet).not.toContain('@media');
2084
+ expect(sheet).toContain(overlayRule('padding:24px 24px!important'));
2085
+ });
2086
+ });
2087
+
2088
+ describe('a block with no id', () => {
2089
+ it('still scopes its own sheet, apart from every other', () => {
2090
+ const first = controlBoxes(
2091
+ renderControls({}, { id: null }).container
2092
+ ).overlay;
2093
+ const second = controlBoxes(
2094
+ renderControls({}, { id: null }).container
2095
+ ).overlay;
2096
+ const firstScope = first.getAttribute('data-pz-video-controls') ?? '';
2097
+ const secondScope = second.getAttribute('data-pz-video-controls') ?? '';
2098
+
2099
+ expect(firstScope).not.toBe('');
2100
+ expect(firstScope).not.toBe('undefined');
2101
+ expect(secondScope).not.toBe(firstScope);
2102
+ // The sheet has to name the scope the element carries, or it styles
2103
+ // nothing at all.
2104
+ [first, second].forEach((overlay, index) => {
2105
+ const scope = index === 0 ? firstScope : secondScope;
2106
+ const sheet =
2107
+ (overlay.previousElementSibling as HTMLStyleElement | null)
2108
+ ?.innerHTML ?? '';
2109
+ expect(sheet).toContain(`[data-pz-video-controls="${scope}"]`);
2110
+ });
2111
+ });
2112
+ });
2113
+
2114
+ describe('mirroring and undoing it between bands', () => {
2115
+ it('writes exactly this for a desktop left and a phone right', () => {
2116
+ const sheet = sheetOf(
2117
+ renderControls({
2118
+ controlPosition: { desktop: 'bottom-left', mobile: 'bottom-right' }
2119
+ }).container
2120
+ );
2121
+
2122
+ expect(sheet).toBe(
2123
+ overlayRule(
2124
+ 'padding:24px 24px!important;justify-content:flex-start!important'
2125
+ ) +
2126
+ columnRule('justify-content:flex-end!important') +
2127
+ railRule(
2128
+ 'bottom:24px!important;flex-direction:row-reverse!important;reading-flow:flex-visual!important'
2129
+ ) +
2130
+ `${MOBILE}{` +
2131
+ overlayRule(
2132
+ 'padding:18px 18px!important;justify-content:flex-end!important'
2133
+ ) +
2134
+ railRule(
2135
+ 'bottom:18px!important;flex-direction:row!important;reading-flow:normal!important'
2136
+ ) +
2137
+ '}'
2138
+ );
2139
+ });
2140
+
2141
+ it('leaves a merchant’s own flex-direction alone on a block anchored right', () => {
2142
+ // The rail inlines no flex-direction, so Custom CSS that stacks the
2143
+ // buttons works today; only a left anchor (or undoing one) may touch it.
2144
+ const sheet = sheetOf(
2145
+ renderControls({
2146
+ controlPosition: { desktop: 'top-right', mobile: 'bottom-center' },
2147
+ customCss: {
2148
+ desktop: '& [data-part="control-rail"] { flex-direction: column; }'
2149
+ }
2150
+ }).container
2151
+ );
2152
+
2153
+ expect(sheet).not.toContain('flex-direction');
2154
+ });
2155
+ });
2156
+
2157
+ describe('custom breakpoints in the editor', () => {
2158
+ beforeEach(() =>
2159
+ registerCustomBreakpoints([
2160
+ { name: 'wide', label: 'Wide', width: 1440, minWidth: 1440 },
2161
+ {
2162
+ name: 'phablet',
2163
+ label: 'Phablet',
2164
+ width: 600,
2165
+ minWidth: 600,
2166
+ maxWidth: 767
2167
+ }
2168
+ ])
2169
+ );
2170
+ afterEach(() => registerCustomBreakpoints([]));
2171
+
2172
+ it('draws the built-in band a custom breakpoint stands on, which is all the storefront sheet carries', () => {
2173
+ // Live has no custom band for this sheet, so a value stored under the
2174
+ // custom key would show in the canvas and never on the page.
2175
+ const wide = sheetOf(
2176
+ renderControls(
2177
+ { controlPosition: { desktop: 'bottom-right', wide: 'top-left' } },
2178
+ { isDesigner: true, currentBreakpoint: 'wide' }
2179
+ ).container
2180
+ );
2181
+ expect(wide).toBe(
2182
+ overlayRule(
2183
+ 'padding:24px 24px!important;justify-content:flex-end!important'
2184
+ ) +
2185
+ columnRule('justify-content:flex-end!important') +
2186
+ railRule('bottom:24px!important')
2187
+ );
2188
+
2189
+ const phablet = sheetOf(
2190
+ renderControls(
2191
+ { controlPosition: { mobile: 'bottom-center', phablet: 'top-left' } },
2192
+ { isDesigner: true, currentBreakpoint: 'phablet' }
2193
+ ).container
2194
+ );
2195
+ expect(phablet).toContain(
2196
+ overlayRule(
2197
+ 'padding:18px 18px!important;justify-content:center!important'
2198
+ )
2199
+ );
2200
+ expect(phablet).not.toContain('flex-direction');
2201
+ });
2202
+ });
2203
+
2204
+ describe('across the server and the client', () => {
2205
+ const element = (id: string | undefined, currentBreakpoint = 'desktop') => (
2206
+ <VideoBlock
2207
+ block={
2208
+ {
2209
+ id,
2210
+ value: FILE,
2211
+ properties: {
2212
+ ...customControls,
2213
+ soundToggle: { desktop: true },
2214
+ controlPosition: { desktop: 'bottom-right', mobile: 'top-left' },
2215
+ controlOffsetX: { desktop: 40 }
2216
+ }
2217
+ } as any
2218
+ }
2219
+ placeholderId="p1"
2220
+ sectionId="s1"
2221
+ isDesigner={false}
2222
+ currentBreakpoint={currentBreakpoint}
2223
+ />
2224
+ );
2225
+
2226
+ it.each([
2227
+ ['with a block id', 'campaign-film'],
2228
+ ['without one, on the useId scope', undefined]
2229
+ ])('hydrates %s, sheet and scope byte for byte', async (_label, id) => {
2230
+ const container = document.createElement('div');
2231
+ container.innerHTML = renderToString(element(id));
2232
+ const serverSheet = container.querySelector('style')?.innerHTML;
2233
+ const serverScope = container
2234
+ .querySelector('[data-part="controls"]')
2235
+ ?.getAttribute('data-pz-video-controls');
2236
+ document.body.appendChild(container);
2237
+
2238
+ const recoverable: unknown[] = [];
2239
+ const errors = jest.spyOn(console, 'error').mockImplementation(() => {});
2240
+ let root: ReturnType<typeof hydrateRoot> | undefined;
2241
+ await act(async () => {
2242
+ root = hydrateRoot(container, element(id), {
2243
+ onRecoverableError: (error) => recoverable.push(error)
2244
+ });
2245
+ });
2246
+
2247
+ expect(recoverable).toEqual([]);
2248
+ expect(errors).not.toHaveBeenCalled();
2249
+ expect(container.querySelector('style')?.innerHTML).toBe(serverSheet);
2250
+ expect(
2251
+ container
2252
+ .querySelector('[data-part="controls"]')
2253
+ ?.getAttribute('data-pz-video-controls')
2254
+ ).toBe(serverScope);
2255
+ expect(serverSheet).toContain(
2256
+ `[data-pz-video-controls="${serverScope}"]`
2257
+ );
2258
+
2259
+ // A phone corrects the prop after mount; the storefront sheet never
2260
+ // listened to it, so nothing moves.
2261
+ await act(async () => root?.render(element(id, 'mobile')));
2262
+ expect(container.querySelector('style')?.innerHTML).toBe(serverSheet);
2263
+
2264
+ errors.mockRestore();
2265
+ await act(async () => root?.unmount());
2266
+ container.remove();
2267
+ });
2268
+ });
2269
+
2270
+ it('survives the layer coming and going', () => {
2271
+ // Nothing here is a hook: VideoBlock returns early after its hooks, so a
2272
+ // hook added beside the overlay would change the count whenever the custom
2273
+ // control or the source kind changes.
2274
+ const view = renderControls({ controlPosition: { desktop: 'top-left' } });
2275
+ const rerender = (properties: Record<string, unknown>, value = FILE) =>
2276
+ view.rerender(
2277
+ <VideoBlock
2278
+ block={
2279
+ {
2280
+ id: 'campaign-film',
2281
+ value,
2282
+ properties: { ...customControls, ...properties }
2283
+ } as any
2284
+ }
2285
+ placeholderId="p1"
2286
+ sectionId="s1"
2287
+ isDesigner={false}
2288
+ />
2289
+ );
2290
+
2291
+ expect(() => {
2292
+ rerender({ customPlayPause: { desktop: false } });
2293
+ rerender({ customPlayPause: { desktop: true } });
2294
+ rerender({}, 'https://www.youtube.com/watch?v=abc123');
2295
+ rerender({});
2296
+ }).not.toThrow();
2297
+ expect(view.container.querySelector('style')).not.toBeNull();
2298
+ });
2299
+ });
@@ -1,13 +1,21 @@
1
1
  'use client';
2
2
 
3
- import React, { useEffect, useRef, useState } from 'react';
3
+ import React, { useEffect, useId, useRef, useState } from 'react';
4
+ import { useBrowseMode } from '../browse-mode';
4
5
  import {
5
6
  BREAKPOINTS,
7
+ MOBILE_MEDIA_QUERY_COMPACT,
6
8
  PHONE_AND_TABLET_MEDIA_CONDITION,
9
+ TABLET_MEDIA_QUERY_COMPACT,
10
+ builtInBreakpointOf,
7
11
  getResponsiveValue,
8
12
  isNarrowBreakpoint,
9
13
  partAttrs
10
14
  } from '../utils';
15
+ import {
16
+ escapeCssAttributeValue,
17
+ neutralizeStyleTagBreakout
18
+ } from '../utils/css-safety';
11
19
  import { BlockRendererProps } from './block-renderer-registry';
12
20
  import {
13
21
  resolveLocalizedValue,
@@ -59,6 +67,16 @@ interface VideoProperties {
59
67
  useDifferentMobileVideo?: unknown;
60
68
  mobileUrl?: unknown;
61
69
  mobilePoster?: unknown;
70
+ /**
71
+ * Where the control layer sits, per breakpoint: `top|middle|bottom` and
72
+ * `left|center|right`, joined (`bottom-right`). Absent means bottom-right,
73
+ * the corner it has always had.
74
+ */
75
+ controlPosition?: unknown;
76
+ /** px from the side it is anchored to, per breakpoint. Absent means the inset. */
77
+ controlOffsetX?: unknown;
78
+ /** px from the top or bottom edge, and from the screen's foot while it follows. */
79
+ controlOffsetY?: unknown;
62
80
  }
63
81
 
64
82
  /**
@@ -155,9 +173,22 @@ const SoundOnIcon = () => (
155
173
  */
156
174
  const VISIBILITY_PLAY_THRESHOLD = 0.1;
157
175
 
158
- /** The inset the control sits at, from the two corners it is anchored to. */
159
- const CONTROL_INSET_DESKTOP = '24px';
160
- const CONTROL_INSET_NARROW = '18px';
176
+ /**
177
+ * The inset the control sits at, from the two corners it is anchored to, and
178
+ * the ceiling an authored distance is clamped to. The placement sheet paints
179
+ * these; the editor's distance sliders stop at the same 200 and show these two
180
+ * numbers for a breakpoint nothing was set at (`VIDEO_CONTROL_INSET` in
181
+ * pz-dynamic-widget) — change them together.
182
+ */
183
+ const CONTROL_INSET_PX = { desktop: 24, narrow: 18 };
184
+ const CONTROL_INSET_MAX_PX = 200;
185
+
186
+ /**
187
+ * The same inset as the inline fallback the sheet overrides — what the control
188
+ * still sits at where the sheet does not reach (a browser without `:where()`).
189
+ */
190
+ const CONTROL_INSET_DESKTOP = `${CONTROL_INSET_PX.desktop}px`;
191
+ const CONTROL_INSET_NARROW = `${CONTROL_INSET_PX.narrow}px`;
161
192
 
162
193
  /**
163
194
  * Asked at the moment it matters rather than held in state: the answer is only
@@ -338,12 +369,226 @@ const firstUrlAtAnyBreakpoint = (value: unknown): string => {
338
369
  return '';
339
370
  };
340
371
 
372
+ /** One half of `controlPosition`, as the flex keyword that puts the rail there. */
373
+ const CONTROL_JUSTIFY: Record<string, string> = {
374
+ left: 'flex-start',
375
+ center: 'center',
376
+ right: 'flex-end'
377
+ };
378
+ const CONTROL_ALIGN: Record<string, string> = {
379
+ top: 'flex-start',
380
+ middle: 'center',
381
+ bottom: 'flex-end'
382
+ };
383
+
384
+ const isOwnKey = (record: Record<string, string>, key: string | undefined) =>
385
+ key !== undefined && Object.prototype.hasOwnProperty.call(record, key);
386
+
387
+ /**
388
+ * The side a Control Overlay `justify-content` part key puts the rail on, or
389
+ * `undefined` when none is authored (or it is not a side this can name).
390
+ */
391
+ const sideOfJustify = (value: unknown): string | undefined => {
392
+ if (value === 'flex-start' || value === 'start' || value === 'left') {
393
+ return 'left';
394
+ }
395
+ if (value === 'center') return 'center';
396
+ if (value === 'flex-end' || value === 'end' || value === 'right') {
397
+ return 'right';
398
+ }
399
+ return undefined;
400
+ };
401
+
402
+ /** `bottom-right` and its eight siblings; anything else is the corner it always had. */
403
+ const readControlPosition = (value: unknown) => {
404
+ const [vertical, horizontal] =
405
+ typeof value === 'string' ? value.split('-') : [];
406
+ return {
407
+ vertical: isOwnKey(CONTROL_ALIGN, vertical) ? vertical : 'bottom',
408
+ horizontal: isOwnKey(CONTROL_JUSTIFY, horizontal) ? horizontal : 'right'
409
+ };
410
+ };
411
+
412
+ /**
413
+ * An authored distance in px, or the fallback.
414
+ *
415
+ * Numbers and numeric strings alike — the editor writes numbers, but a
416
+ * template, an import or a hand-edited theme can carry `'40'`, and a reader
417
+ * that only took `typeof === 'number'` would drop every one of them silently.
418
+ * A 0 is a distance, not an absence. Clamped to what the editor offers, so the
419
+ * page never draws a distance the panel cannot show.
420
+ */
421
+ const readControlInset = (value: unknown, fallback: number): number => {
422
+ if (value === undefined || value === null || value === '') return fallback;
423
+ const parsed = typeof value === 'number' ? value : parseFloat(String(value));
424
+ if (!Number.isFinite(parsed)) return fallback;
425
+ return Math.min(CONTROL_INSET_MAX_PX, Math.max(0, Math.round(parsed)));
426
+ };
427
+
428
+ /** What one breakpoint band asks of the control layer. */
429
+ interface ControlBand {
430
+ /** Overlay padding: the distance from the edges it is anchored to. */
431
+ padding: string;
432
+ /** The rail's sticky offset — the same distance, kept from the screen's foot. */
433
+ bottom: string;
434
+ /** Overlay `justify-content` (horizontal), only once a position is authored. */
435
+ justify?: string;
436
+ /** Column `justify-content` (vertical), only once a position is authored. */
437
+ align?: string;
438
+ /** Left anchors mirror the pair so play/pause keeps the outer corner. */
439
+ mirrored?: boolean;
440
+ }
441
+
442
+ const controlBandAt = (
443
+ properties: VideoProperties,
444
+ styles: Record<string, unknown> | undefined,
445
+ band: string,
446
+ placed: boolean
447
+ ): ControlBand => {
448
+ const inset = isNarrowBreakpoint(band)
449
+ ? CONTROL_INSET_PX.narrow
450
+ : CONTROL_INSET_PX.desktop;
451
+ const x = readControlInset(
452
+ getResponsiveValue(properties.controlOffsetX, band, undefined),
453
+ inset
454
+ );
455
+ const y = readControlInset(
456
+ getResponsiveValue(properties.controlOffsetY, band, undefined),
457
+ inset
458
+ );
459
+ const result: ControlBand = { padding: `${y}px ${x}px`, bottom: `${y}px` };
460
+ if (!placed) return result;
461
+
462
+ const { vertical, horizontal } = readControlPosition(
463
+ getResponsiveValue(properties.controlPosition, band, undefined)
464
+ );
465
+ result.justify = CONTROL_JUSTIFY[horizontal];
466
+ result.align = CONTROL_ALIGN[vertical];
467
+ /*
468
+ * Mirrored by the side the rail actually ends up on. A Horizontal Corner
469
+ * stored on the Control Overlay part outranks the preset's justify-content,
470
+ * so mirroring by the preset alone would put the sound button in the corner
471
+ * on exactly the blocks that have one. Only once a position is authored,
472
+ * though: a block that has nothing but a legacy Left keeps the order it
473
+ * renders in today.
474
+ */
475
+ const side =
476
+ sideOfJustify(
477
+ getResponsiveValue(styles?.['controls/justify-content'], band, undefined)
478
+ ) ?? horizontal;
479
+ if (side === 'left') result.mirrored = true;
480
+ return result;
481
+ };
482
+
483
+ /**
484
+ * The rules one band writes — only what differs from the band it inherits in
485
+ * the cascade, so a phone value equal to the desktop one is not written twice.
486
+ */
487
+ const controlBandRules = (
488
+ scope: string,
489
+ band: ControlBand,
490
+ inherited?: ControlBand
491
+ ): string => {
492
+ const changed = (key: keyof ControlBand) =>
493
+ !inherited || band[key] !== inherited[key];
494
+
495
+ const overlay: string[] = [];
496
+ const column: string[] = [];
497
+ const rail: string[] = [];
498
+
499
+ if (changed('padding')) overlay.push(`padding:${band.padding}!important`);
500
+ if (band.justify && changed('justify')) {
501
+ overlay.push(`justify-content:${band.justify}!important`);
502
+ }
503
+ if (band.align && changed('align')) {
504
+ column.push(`justify-content:${band.align}!important`);
505
+ }
506
+ if (changed('bottom')) rail.push(`bottom:${band.bottom}!important`);
507
+ if (band.mirrored && changed('mirrored')) {
508
+ rail.push(
509
+ 'flex-direction:row-reverse!important',
510
+ 'reading-flow:flex-visual!important'
511
+ );
512
+ } else if (!band.mirrored && inherited?.mirrored) {
513
+ // Undo a wider band's mirror. Never written otherwise: the rail carries no
514
+ // inline flex-direction, so a merchant's own Custom CSS one wins today and
515
+ // an unconditional `row` here would silently take that away.
516
+ rail.push('flex-direction:row!important', 'reading-flow:normal!important');
517
+ }
518
+
519
+ const root = `[data-pz-video-controls="${scope}"]`;
520
+ return (
521
+ (overlay.length ? `:where(${root}){${overlay.join(';')}}` : '') +
522
+ (column.length ? `:where(${root}>div){${column.join(';')}}` : '') +
523
+ (rail.length
524
+ ? `:where(${root} [data-part="control-rail"]){${rail.join(';')}}`
525
+ : '')
526
+ );
527
+ };
528
+
529
+ /**
530
+ * WHERE THE CONTROL LAYER SITS, AS A STYLESHEET (ZERO-5177).
531
+ *
532
+ * `controlPosition` and the two distances are properties stored per
533
+ * breakpoint, and resolving them at `currentBreakpoint` into the inline styles
534
+ * would paint a phone with the desktop answer: that prop is `'desktop'` on the
535
+ * server and through a phone's first render. So they become CSS — every band at
536
+ * once with real media queries on the storefront, and the one band the editor
537
+ * is forcing whenever it is attached, editing or browsing (a custom breakpoint
538
+ * as the built-in band it stands on, since the storefront sheet only carries
539
+ * the built-in three — the canvas must not show what the page will not).
540
+ *
541
+ * Every selector is wrapped in `:where()` and every declaration is
542
+ * `!important`: specificity 0 beats nothing, so what this outranks is the
543
+ * renderer's own inline defaults — which no stylesheet rule can reach otherwise
544
+ * — and, for the properties it writes, any rule without `!important`, exactly
545
+ * as those inline values already did. Everything authored with an
546
+ * `!important` of its own still wins — the Control Overlay part keys
547
+ * (`[data-block-id] [data-part]`, 0,2,0), which are the fine-tuning on top of
548
+ * this, and any brand override that beats the inline styles today. The one
549
+ * property it writes that is NOT inline, the rail's flex-direction (with
550
+ * reading-flow), is therefore written only around a left anchor.
551
+ *
552
+ * The distances are written whatever was authored, because the default inset
553
+ * is per band too: the inline 24px is chosen from `currentBreakpoint` and a
554
+ * phone used to paint it before jumping to 18px.
555
+ */
556
+ export const buildVideoControlSheet = (
557
+ scope: string,
558
+ properties: VideoProperties,
559
+ styles: Record<string, unknown> | undefined,
560
+ designerBand?: string
561
+ ): string => {
562
+ const placed = setAtAnyBreakpoint(properties.controlPosition);
563
+
564
+ if (designerBand) {
565
+ return controlBandRules(
566
+ scope,
567
+ controlBandAt(properties, styles, designerBand, placed)
568
+ );
569
+ }
570
+
571
+ const desktop = controlBandAt(properties, styles, 'desktop', placed);
572
+ const mobile = controlBandAt(properties, styles, 'mobile', placed);
573
+ const tablet = controlBandAt(properties, styles, 'tablet', placed);
574
+ const mobileRules = controlBandRules(scope, mobile, desktop);
575
+ // Tablet is written after mobile and inherits it, the generator's cascade.
576
+ const tabletRules = controlBandRules(scope, tablet, mobile);
577
+
578
+ return (
579
+ controlBandRules(scope, desktop) +
580
+ (mobileRules ? `${MOBILE_MEDIA_QUERY_COMPACT}{${mobileRules}}` : '') +
581
+ (tabletRules ? `${TABLET_MEDIA_QUERY_COMPACT}{${tabletRules}}` : '')
582
+ );
583
+ };
584
+
341
585
  /**
342
586
  * A video: a YouTube or Vimeo embed, or a file the browser plays itself.
343
587
  *
344
588
  * Properties: url (on `block.value`), controls, autoplay, loop, muted,
345
589
  * playsInline, poster, sourceType, preload, playWhenVisible, customPlayPause,
346
- * soundToggle. Style: `aspect-ratio`.
590
+ * soundToggle, controlPosition, controlOffsetX, controlOffsetY. Style:
591
+ * `aspect-ratio`.
347
592
  */
348
593
  const VideoBlock = ({
349
594
  block,
@@ -351,6 +596,20 @@ const VideoBlock = ({
351
596
  currentBreakpoint = 'desktop'
352
597
  }: BlockRendererProps) => {
353
598
  const valueLocale = useValueLocale();
599
+ /*
600
+ * Two reads for the placement sheet, taken here with the other hooks:
601
+ * VideoBlock returns early further down (no URL, an embed, no custom
602
+ * control), so a hook beside the sheet would change the count whenever one
603
+ * of those flips.
604
+ *
605
+ * Browse mode is the editor still attached with its affordances off. Blocks
606
+ * get `isDesigner === false` there, but the canvas is still the editor's
607
+ * zoomed frame, and WebKit lays a zoomed desktop frame out at its zoomed
608
+ * width — a real media query answers "phone" on a desktop canvas. False on
609
+ * the server and through hydration, so it cannot split the markup.
610
+ */
611
+ const browsing = useBrowseMode();
612
+ const fallbackControlScope = useId();
354
613
  const localizedValue = resolveLocalizedValue(block.value, valueLocale);
355
614
  const desktopUrl = (
356
615
  typeof localizedValue === 'string' ? localizedValue : block.value
@@ -1011,6 +1270,13 @@ const VideoBlock = ({
1011
1270
  /**
1012
1271
  * THE CONTROL LAYER — BOTTOM RIGHT, AND STILL THERE ON A TALL CLIP.
1013
1272
  *
1273
+ * Bottom right is the default, and what the inline styles below still draw.
1274
+ * An author can move it (Button Position and the two distances, ZERO-5177):
1275
+ * that arrives as the placement sheet, which overrides these inline values —
1276
+ * see `buildVideoControlSheet`. A middle anchor keeps the sticky rail, so it
1277
+ * rides the viewport floor until its own spot scrolls into view; a top
1278
+ * anchor is already at the top of its column, where sticky has no room.
1279
+ *
1014
1280
  * Still no wrapper around the VIDEO: `[data-block-id]` (rendered by
1015
1281
  * `WithDesignerFeatures`) is already `position: relative` and already carries
1016
1282
  * whatever height/position the author's block styles give it — a full-bleed
@@ -1032,7 +1298,8 @@ const VideoBlock = ({
1032
1298
  * being watched. Sticky keeps it the same inset above the viewport floor
1033
1299
  * until the real bottom comes into range, then parks it there. Where an
1034
1300
  * ancestor's `overflow: hidden` defeats sticky, it degrades to its static
1035
- * position, which is the bottom-right corner it was going to sit in anyway.
1301
+ * position — the anchor it was placed at anyway (bottom right unless Button
1302
+ * Position says otherwise).
1036
1303
  *
1037
1304
  * Both outer boxes are `pointer-events: none` — the overlay covers the whole
1038
1305
  * player, and the shopper must still be able to click a link sitting under
@@ -1062,11 +1329,37 @@ const VideoBlock = ({
1062
1329
  pointerEvents: 'auto'
1063
1330
  };
1064
1331
 
1332
+ /*
1333
+ * Where the author put the layer — see `buildVideoControlSheet`. Scoped by
1334
+ * the block id the way every other scoped sheet in this package is: data, so
1335
+ * the server and the first client render name the same scope. A block with
1336
+ * no id (only tests and hand-built trees render one) falls back to `useId`,
1337
+ * so two of them never share a sheet. The raw id goes on the element and the
1338
+ * escaped one in the selector.
1339
+ *
1340
+ * The `<style>` sits BEFORE the overlay so the overlay stays the wrapper's
1341
+ * last child — the generator's root hover rule reaches the buttons through
1342
+ * `> div:last-child button`.
1343
+ */
1344
+ const controlScope = String(block.id || fallbackControlScope);
1345
+ const controlSheet = buildVideoControlSheet(
1346
+ escapeCssAttributeValue(controlScope),
1347
+ properties,
1348
+ block.styles as Record<string, unknown> | undefined,
1349
+ isDesigner || browsing ? builtInBreakpointOf(currentBreakpoint) : undefined
1350
+ );
1351
+
1065
1352
  return (
1066
1353
  <>
1067
1354
  {videoElement}
1355
+ <style
1356
+ dangerouslySetInnerHTML={{
1357
+ __html: neutralizeStyleTagBreakout(controlSheet)
1358
+ }}
1359
+ />
1068
1360
  <div
1069
1361
  {...partAttrs('controls')}
1362
+ data-pz-video-controls={controlScope}
1070
1363
  style={{
1071
1364
  position: 'absolute',
1072
1365
  inset: 0,