@akinon/pz-theme 2.0.36-rc.0 → 2.0.37-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +16 -31
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. package/src/utils/visibility-rules.ts +346 -3
@@ -5,8 +5,19 @@ import {
5
5
  neutralizeStyleTagBreakout,
6
6
  escapeCssAttributeValue
7
7
  } from './css-safety';
8
+ import { buildPartSelectors, splitPartStyles } from './part-styles';
9
+ import { buildCustomCss } from './custom-css';
10
+ import { hasBindingToken } from './iterator-utils';
11
+ import {
12
+ BREAKPOINT_FALLBACKS,
13
+ MOBILE_MEDIA_QUERY,
14
+ TABLET_MEDIA_QUERY
15
+ } from './breakpoints';
8
16
 
9
17
  export * from './publish-window';
18
+ export * from './part-styles';
19
+ export * from './custom-css';
20
+ export * from './breakpoints';
10
21
 
11
22
  export type ImageLoadingStrategy = 'eager' | 'lazy';
12
23
  export type ImageFetchPriority = 'high' | 'auto' | 'low';
@@ -196,6 +207,20 @@ export const getResponsiveValue = (
196
207
  const responsiveValue = value as Record<string, unknown>;
197
208
  const matched = responsiveValue[breakpoint];
198
209
  if (matched !== undefined) return matched;
210
+
211
+ // Nothing authored here, so fall through the way the emitted cascade does:
212
+ // a tablet reads the mobile value before it reads the desktop one, because
213
+ // the mobile rule is the one that covers 768-1023 when no tablet rule was
214
+ // emitted. `desktop` and `mobile` have no intermediate step, so this loop
215
+ // does not run for them and they resolve exactly as they always have.
216
+ const chain = BREAKPOINT_FALLBACKS[breakpoint];
217
+ if (chain) {
218
+ for (const next of chain) {
219
+ const inherited = responsiveValue[next];
220
+ if (inherited !== undefined) return inherited;
221
+ }
222
+ }
223
+
199
224
  if (responsiveValue.desktop !== undefined) return responsiveValue.desktop;
200
225
  return fallback;
201
226
  }
@@ -251,13 +276,59 @@ const kebabToCamel = (str: string): string => {
251
276
  return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
252
277
  };
253
278
 
279
+ /**
280
+ * A style value carrying a binding token (`{{item.badgeColor}}`) is an
281
+ * instruction the iterator resolves per row, not a CSS value. It is removed
282
+ * before emission for two reasons: a token that reached the stylesheet would
283
+ * write a declaration the browser throws away (so an unresolved binding must
284
+ * leave the property unset, not half-written), and nothing resembling a token
285
+ * should ever be interpolated into a `<style>` element.
286
+ *
287
+ * Styles with no token are returned by reference, so every composition that
288
+ * renders today produces byte-identical CSS.
289
+ */
290
+ const stripBoundStyleValues = <T extends Record<string, unknown>>(
291
+ styles: T
292
+ ): T => {
293
+ let changed = false;
294
+ const stripped: Record<string, unknown> = {};
295
+
296
+ Object.entries(styles).forEach(([key, value]) => {
297
+ if (hasBindingToken(value)) {
298
+ changed = true;
299
+ return;
300
+ }
301
+
302
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
303
+ const entries = Object.entries(value as Record<string, unknown>);
304
+ if (
305
+ entries.some(([, breakpointValue]) => hasBindingToken(breakpointValue))
306
+ ) {
307
+ changed = true;
308
+ const kept = entries.filter(
309
+ ([, breakpointValue]) => !hasBindingToken(breakpointValue)
310
+ );
311
+ if (kept.length > 0) {
312
+ stripped[key] = Object.fromEntries(kept);
313
+ }
314
+ return;
315
+ }
316
+ }
317
+
318
+ stripped[key] = value;
319
+ });
320
+
321
+ return changed ? (stripped as T) : styles;
322
+ };
323
+
254
324
  export const getCSSStyles = (
255
- styles?: Record<string, unknown>,
325
+ rawStyles?: Record<string, unknown>,
256
326
  themeSettings?: ThemeSettings,
257
327
  breakpoint: string = 'desktop'
258
328
  ): React.CSSProperties => {
259
- if (!styles) return {};
329
+ if (!rawStyles) return {};
260
330
 
331
+ const styles = stripBoundStyleValues(rawStyles);
261
332
  const cssStyles: Record<string, string | number> = {};
262
333
 
263
334
  const normalizeKey = (key: string): string => {
@@ -377,22 +448,62 @@ interface ResponsiveStyles {
377
448
  [key: string]:
378
449
  | {
379
450
  desktop?: string | number;
451
+ tablet?: string | number;
380
452
  mobile?: string | number;
381
453
  }
382
454
  | string
383
455
  | number;
384
456
  }
385
457
 
458
+ type ResponsiveEntry = {
459
+ desktop?: string | number;
460
+ tablet?: string | number;
461
+ mobile?: string | number;
462
+ };
463
+
464
+ /** Authored at all, as the generator has always judged it. */
465
+ const isAuthored = (value: unknown): value is string | number =>
466
+ value !== undefined && value !== null && value !== '';
467
+
468
+ /**
469
+ * The value a breakpoint resolves to inside a responsive entry, following the
470
+ * same chain as `getResponsiveValue` — so the stylesheet and the value readers
471
+ * cannot disagree about what "nothing authored here" means.
472
+ */
473
+ const resolveEntryValue = (
474
+ entry: ResponsiveEntry,
475
+ breakpoint: string
476
+ ): string | number | undefined => {
477
+ const own = entry[breakpoint as keyof ResponsiveEntry];
478
+ if (own !== undefined) return own;
479
+
480
+ const chain = BREAKPOINT_FALLBACKS[breakpoint];
481
+ if (chain) {
482
+ for (const next of chain) {
483
+ const inherited = entry[next as keyof ResponsiveEntry];
484
+ if (inherited !== undefined) return inherited;
485
+ }
486
+ }
487
+
488
+ return entry.desktop;
489
+ };
490
+
386
491
  interface Block {
387
492
  id: string;
388
493
  styleSourceId?: string;
389
494
  styles?: ResponsiveStyles;
495
+ /** Set by blocks that name their real root with `data-part="root"`. */
496
+ stylesTargetRoot?: boolean;
497
+ // Only read for the scoped custom-CSS escape hatch; the rest of a block's
498
+ // properties never reach the stylesheet.
499
+ properties?: Record<string, unknown>;
390
500
  blocks?: Block[];
391
501
  }
392
502
 
393
503
  interface Section {
394
504
  id: string;
395
505
  styles?: ResponsiveStyles;
506
+ properties?: Record<string, unknown>;
396
507
  blocks?: Block[];
397
508
  }
398
509
 
@@ -472,10 +583,21 @@ const formatCSSValue = (property: string, value: string | number): string => {
472
583
 
473
584
  const generateElementCSS = (
474
585
  selector: string,
475
- styles: ResponsiveStyles,
476
- targetBreakpoint?: string
586
+ rawStyles: ResponsiveStyles,
587
+ targetBreakpoint?: string,
588
+ options?: { treatHelperKeysAsCss?: boolean }
477
589
  ): string => {
478
- if (!styles || Object.keys(styles).length === 0) return '';
590
+ if (!rawStyles || Object.keys(rawStyles).length === 0) return '';
591
+
592
+ // Bound values belong to the per-row resolution, not to the shared rule.
593
+ const styles = stripBoundStyleValues(
594
+ rawStyles as Record<string, unknown>
595
+ ) as ResponsiveStyles;
596
+
597
+ // `opacity` and friends are helper keys on a block root, feeding the
598
+ // hover/background-alpha maths rather than being emitted directly. A named
599
+ // part has no such helper, so there they mean the CSS property.
600
+ const helperKeysAreCss = options?.treatHelperKeysAsCss === true;
479
601
 
480
602
  const getStyleEntry = (kebabKey: string): unknown => {
481
603
  const camelKey = kebabToCamel(kebabKey);
@@ -580,27 +702,33 @@ const generateElementCSS = (
580
702
 
581
703
  // Skip non-CSS helper keys.
582
704
  if (
583
- cssProperty === 'text-opacity' ||
584
- cssProperty === 'background-opacity' ||
585
- cssProperty === 'hover-opacity' ||
586
- cssProperty === 'hover-color' ||
587
- cssProperty === 'hover-flex' ||
588
- cssProperty === 'hover-child-flex' ||
589
- cssProperty === 'opacity'
705
+ !helperKeysAreCss &&
706
+ (cssProperty === 'text-opacity' ||
707
+ cssProperty === 'background-opacity' ||
708
+ cssProperty === 'hover-opacity' ||
709
+ cssProperty === 'hover-color' ||
710
+ cssProperty === 'hover-flex' ||
711
+ cssProperty === 'hover-child-flex' ||
712
+ cssProperty === 'opacity')
590
713
  ) {
591
714
  return;
592
715
  }
593
716
 
594
717
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
595
- const responsiveValue = value as {
596
- desktop?: string | number;
597
- mobile?: string | number;
598
- };
718
+ // `ResponsiveEntry`, not an inline copy of it: the copies predated the
719
+ // tablet breakpoint and silently lacked it, so reading `.tablet` here
720
+ // was a type error only the app build could see.
721
+ const responsiveValue = value as ResponsiveEntry;
599
722
 
600
723
  if (targetBreakpoint) {
601
- const breakpointValue =
602
- responsiveValue[targetBreakpoint as keyof typeof responsiveValue] ??
603
- responsiveValue.desktop;
724
+ // The canvas shows one viewport, so it gets the value that viewport
725
+ // resolves to — including the tablet -> mobile -> desktop fall-through.
726
+ // Reading only `[breakpoint] ?? desktop` here is what would make the
727
+ // canvas show a tablet the device never renders.
728
+ const breakpointValue = resolveEntryValue(
729
+ responsiveValue,
730
+ targetBreakpoint
731
+ );
604
732
  if (
605
733
  breakpointValue !== undefined &&
606
734
  breakpointValue !== null &&
@@ -610,9 +738,7 @@ const generateElementCSS = (
610
738
  cssProperty === 'background-color' &&
611
739
  typeof breakpointValue === 'string'
612
740
  ) {
613
- const backgroundAlpha = getBackgroundAlpha(
614
- targetBreakpoint === 'mobile' ? 'mobile' : 'desktop'
615
- );
741
+ const backgroundAlpha = getBackgroundAlpha(targetBreakpoint);
616
742
  if (backgroundAlpha !== null) {
617
743
  if (backgroundAlpha === 0) {
618
744
  cssStyles.push(` ${cssProperty}: transparent`);
@@ -691,8 +817,11 @@ const generateElementCSS = (
691
817
  });
692
818
 
693
819
  if (targetBreakpoint) {
694
- const breakpointKey = targetBreakpoint === 'mobile' ? 'mobile' : 'desktop';
695
- const hoverCss = buildHoverCss(breakpointKey);
820
+ // Passed straight through rather than folded to desktop/mobile: the hover
821
+ // helpers read through `getResponsiveValue`, which already knows where a
822
+ // breakpoint looks when it has nothing of its own. For 'desktop' and
823
+ // 'mobile' this resolves to what the fold produced.
824
+ const hoverCss = buildHoverCss(targetBreakpoint);
696
825
 
697
826
  if (cssStyles.length > 0) {
698
827
  return `${selector} {\n${cssStyles.join(';\n')};\n}\n${hoverCss}`;
@@ -707,6 +836,32 @@ const generateElementCSS = (
707
836
 
708
837
  const desktopStyles: string[] = [];
709
838
  const mobileStyles: string[] = [];
839
+ const tabletStyles: string[] = [];
840
+
841
+ /**
842
+ * The declaration value one breakpoint would produce for one property, by
843
+ * the same rules the desktop and mobile arms below apply inline. Only the
844
+ * tablet arm calls it, so the two older arms keep emitting the bytes they
845
+ * always emitted; the tablet arm uses it twice — once for its own value and
846
+ * once for the value it would inherit — to decide whether a rule is worth
847
+ * writing at all.
848
+ */
849
+ const declarationValue = (
850
+ property: string,
851
+ cssProperty: string,
852
+ breakpoint: string,
853
+ raw: string | number
854
+ ): string => {
855
+ if (cssProperty === 'background-color' && typeof raw === 'string') {
856
+ const backgroundAlpha = getBackgroundAlpha(breakpoint);
857
+ if (backgroundAlpha !== null) {
858
+ if (backgroundAlpha === 0) return 'transparent';
859
+ return colorToRgba(raw, backgroundAlpha) || raw;
860
+ }
861
+ }
862
+
863
+ return formatCSSValue(property, raw);
864
+ };
710
865
 
711
866
  Object.entries(styles).forEach(([property, value]) => {
712
867
  if (value === undefined || value === null) return;
@@ -715,22 +870,23 @@ const generateElementCSS = (
715
870
 
716
871
  // Skip non-CSS helper keys.
717
872
  if (
718
- cssProperty === 'text-opacity' ||
719
- cssProperty === 'background-opacity' ||
720
- cssProperty === 'hover-opacity' ||
721
- cssProperty === 'hover-color' ||
722
- cssProperty === 'hover-flex' ||
723
- cssProperty === 'hover-child-flex' ||
724
- cssProperty === 'opacity'
873
+ !helperKeysAreCss &&
874
+ (cssProperty === 'text-opacity' ||
875
+ cssProperty === 'background-opacity' ||
876
+ cssProperty === 'hover-opacity' ||
877
+ cssProperty === 'hover-color' ||
878
+ cssProperty === 'hover-flex' ||
879
+ cssProperty === 'hover-child-flex' ||
880
+ cssProperty === 'opacity')
725
881
  ) {
726
882
  return;
727
883
  }
728
884
 
729
885
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
730
- const responsiveValue = value as {
731
- desktop?: string | number;
732
- mobile?: string | number;
733
- };
886
+ // `ResponsiveEntry`, not an inline copy of it: the copies predated the
887
+ // tablet breakpoint and silently lacked it, so reading `.tablet` here
888
+ // was a type error only the app build could see.
889
+ const responsiveValue = value as ResponsiveEntry;
734
890
 
735
891
  if (
736
892
  responsiveValue.desktop !== undefined &&
@@ -822,6 +978,40 @@ const generateElementCSS = (
822
978
  }
823
979
  }
824
980
  }
981
+
982
+ // Tablet, last, and only when something is authored here. That guard is
983
+ // what makes every composition published before this breakpoint existed
984
+ // emit the exact bytes it emitted before: with no tablet key there is no
985
+ // tablet declaration, so there is no tablet rule.
986
+ if (isAuthored(responsiveValue.tablet)) {
987
+ const formattedTablet = declarationValue(
988
+ property,
989
+ cssProperty,
990
+ 'tablet',
991
+ responsiveValue.tablet
992
+ );
993
+
994
+ // What 768-1023 would show without this rule: the mobile declaration
995
+ // if one was authored (its query covers the band), otherwise desktop's.
996
+ // Matching it means the rule would change nothing, so it is not
997
+ // written — the same reasoning that already suppresses a mobile value
998
+ // equal to desktop.
999
+ const inheritedRaw = isAuthored(responsiveValue.mobile)
1000
+ ? responsiveValue.mobile
1001
+ : responsiveValue.desktop;
1002
+ const inherited = isAuthored(inheritedRaw)
1003
+ ? declarationValue(
1004
+ property,
1005
+ cssProperty,
1006
+ isAuthored(responsiveValue.mobile) ? 'mobile' : 'desktop',
1007
+ inheritedRaw
1008
+ )
1009
+ : '';
1010
+
1011
+ if (formattedTablet && formattedTablet !== inherited) {
1012
+ tabletStyles.push(` ${cssProperty}: ${formattedTablet}`);
1013
+ }
1014
+ }
825
1015
  } else {
826
1016
  if (cssProperty === 'background-color' && typeof value === 'string') {
827
1017
  const backgroundAlpha = getBackgroundAlpha('desktop');
@@ -851,14 +1041,80 @@ const generateElementCSS = (
851
1041
 
852
1042
  css += buildHoverCss('desktop');
853
1043
 
1044
+ const mobileHoverCss = buildHoverCss('mobile', ' ');
1045
+
854
1046
  if (mobileStyles.length > 0) {
855
- const mobileHoverCss = buildHoverCss('mobile', ' ');
856
1047
 
857
- css += `@media (max-width: 1023px) {\n ${selector} {\n ${mobileStyles.join(
1048
+ css += `${MOBILE_MEDIA_QUERY} {\n ${selector} {\n ${mobileStyles.join(
858
1049
  ';\n '
859
1050
  )};\n }\n${mobileHoverCss}}\n`;
860
1051
  }
861
1052
 
1053
+ // After the mobile query, never before it. Both match at 900px, both carry
1054
+ // the same selector and therefore the same specificity, so the one written
1055
+ // second is the one that wins — that ordering is the entire mechanism by
1056
+ // which a tablet value beats the mobile value it falls back to.
1057
+ const tabletHoverCss = buildHoverCss('tablet', ' ');
1058
+ // What is already painting at 900px: the mobile query when one was written,
1059
+ // otherwise the base rule. A helper key (hover-*, background-opacity) never
1060
+ // reaches `tabletStyles`, so keying the query on that array alone dropped a
1061
+ // tablet-authored hover entirely.
1062
+ const inheritedHoverCss =
1063
+ mobileStyles.length > 0 ? mobileHoverCss : buildHoverCss('desktop', ' ');
1064
+
1065
+ if (tabletStyles.length > 0 || tabletHoverCss !== inheritedHoverCss) {
1066
+
1067
+ const tabletDeclarations =
1068
+ tabletStyles.length > 0
1069
+ ? ` ${selector} {\n ${tabletStyles.join(';\n ')};\n }\n`
1070
+ : '';
1071
+
1072
+ css += `${TABLET_MEDIA_QUERY} {\n${tabletDeclarations}${tabletHoverCss}}\n`;
1073
+ }
1074
+
1075
+ return css;
1076
+ };
1077
+
1078
+ /**
1079
+ * Rules for the named parts inside a functional block.
1080
+ *
1081
+ * The element being targeted almost always carries the renderer's own inline
1082
+ * defaults, and inline styles outrank any stylesheet — so a designer's
1083
+ * override is emitted `!important`. Without it the override would be written,
1084
+ * saved, shown in the panel, and then quietly do nothing.
1085
+ */
1086
+ const generatePartCSS = (
1087
+ blockSelector: string,
1088
+ styles: Record<string, unknown>,
1089
+ targetBreakpoint?: string
1090
+ ): string => {
1091
+ const { parts } = splitPartStyles(styles);
1092
+ if (parts.length === 0) return '';
1093
+
1094
+ let css = '';
1095
+
1096
+ parts.forEach((bucket) => {
1097
+ const selectors = buildPartSelectors(blockSelector, bucket);
1098
+ if (selectors.length === 0) return;
1099
+
1100
+ const rules = generateElementCSS(
1101
+ selectors.join(',\n'),
1102
+ bucket.styles as ResponsiveStyles,
1103
+ targetBreakpoint,
1104
+ { treatHelperKeysAsCss: true }
1105
+ );
1106
+
1107
+ if (!rules) return;
1108
+
1109
+ css += rules.replace(
1110
+ /^(\s*)([a-z-]+):\s*([^;\n]+);/gm,
1111
+ (line, indent, property, value) =>
1112
+ value.includes('!important')
1113
+ ? line
1114
+ : `${indent}${property}: ${value} !important;`
1115
+ );
1116
+ });
1117
+
862
1118
  return css;
863
1119
  };
864
1120
 
@@ -873,7 +1129,37 @@ const collectBlockCSS = (
873
1129
  const selector = `[data-block-id="${escapeCssAttributeValue(
874
1130
  block.styleSourceId || block.id
875
1131
  )}"]`;
876
- css += generateElementCSS(selector, block.styles, targetBreakpoint);
1132
+ // Root styles ignore part keys, so a part override never leaks onto the
1133
+ // block wrapper (a padding meant for the dropdown would otherwise
1134
+ // reposition the whole block).
1135
+ const { root } = splitPartStyles(block.styles);
1136
+
1137
+ if (Object.keys(root).length > 0) {
1138
+ // `data-block-id` sits on the designer wrapper, not on the element the
1139
+ // renderer actually draws — so a padding or border set on a functional
1140
+ // block stopped at a transparent div and never reached the control.
1141
+ // A block that names its real root with `data-part="root"` gets its
1142
+ // own styles routed there instead. Blocks that do not are untouched,
1143
+ // which is what keeps published compositions rendering as before.
1144
+ const rootSelector = block.stylesTargetRoot
1145
+ ? `${selector} [data-part="root"]`
1146
+ : selector;
1147
+
1148
+ css += generateElementCSS(rootSelector, root, targetBreakpoint);
1149
+ }
1150
+
1151
+ css += generatePartCSS(selector, block.styles, targetBreakpoint);
1152
+ }
1153
+
1154
+ if (block.properties) {
1155
+ const selector = `[data-block-id="${escapeCssAttributeValue(
1156
+ block.styleSourceId || block.id
1157
+ )}"]`;
1158
+ css += buildCustomCss(
1159
+ block.properties as Record<string, unknown>,
1160
+ selector,
1161
+ targetBreakpoint
1162
+ );
877
1163
  }
878
1164
 
879
1165
  if (block.blocks && block.blocks.length > 0) {
@@ -891,11 +1177,36 @@ export const generateThemeCSS = (
891
1177
  let css = '';
892
1178
 
893
1179
  sections.forEach((section) => {
1180
+ const sectionSelector = `[data-section-id="${escapeCssAttributeValue(
1181
+ section.id
1182
+ )}"]`;
1183
+
894
1184
  if (section.styles && Object.keys(section.styles).length > 0) {
895
- const selector = `[data-section-id="${escapeCssAttributeValue(
896
- section.id
897
- )}"]`;
898
- css += generateElementCSS(selector, section.styles, targetBreakpoint);
1185
+ // Sections went straight to generateElementCSS, so a part key on a
1186
+ // section was emitted as the invalid declaration `dropdown/color:`.
1187
+ const { root: sectionRoot } = splitPartStyles(section.styles);
1188
+
1189
+ if (Object.keys(sectionRoot).length > 0) {
1190
+ css += generateElementCSS(
1191
+ sectionSelector,
1192
+ sectionRoot,
1193
+ targetBreakpoint
1194
+ );
1195
+ }
1196
+
1197
+ css += generatePartCSS(
1198
+ sectionSelector,
1199
+ section.styles,
1200
+ targetBreakpoint
1201
+ );
1202
+ }
1203
+
1204
+ if (section.properties) {
1205
+ css += buildCustomCss(
1206
+ section.properties as Record<string, unknown>,
1207
+ sectionSelector,
1208
+ targetBreakpoint
1209
+ );
899
1210
  }
900
1211
 
901
1212
  if (section.blocks && section.blocks.length > 0) {