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

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 +5 -4
  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
@@ -0,0 +1,298 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import ThemeBlock from '../../theme-block';
4
+ import { generateThemeCSS } from '../index';
5
+ import { buildPartSelectors } from '../part-styles';
6
+ import { buildIteratorBlock } from '../iterator-utils';
7
+ import type { Section } from '../../theme-section';
8
+
9
+ /**
10
+ * The part-ownership guard, pushed past the two-block case.
11
+ *
12
+ * A part rule is confined to its declaring block by excluding anything sitting
13
+ * inside a nested block. That is only safe while two things hold, and neither
14
+ * is obvious by reading the selector:
15
+ *
16
+ * 1. no renderer draws its own part inside a child block's wrapper — if one
17
+ * did, the guard would delete a style the block legitimately owns;
18
+ * 2. the exclusion reaches all the way down, not just one level.
19
+ *
20
+ * Both are asserted here against shipped renderers and against repeated rows,
21
+ * where clones deliberately share one style identity.
22
+ */
23
+
24
+ const group = (id: string, label: string, children: unknown[] = []) => ({
25
+ id,
26
+ type: 'group',
27
+ label,
28
+ properties: {},
29
+ styles: {},
30
+ order: 0,
31
+ hidden: false,
32
+ blocks: children
33
+ });
34
+
35
+ const text = (id: string) => ({
36
+ id,
37
+ type: 'text',
38
+ label: 'Text',
39
+ value: '<p>copy</p>',
40
+ properties: {},
41
+ styles: {},
42
+ order: 0,
43
+ hidden: false
44
+ });
45
+
46
+ const accordion = (
47
+ id: string,
48
+ styles: Record<string, unknown> = {},
49
+ inner?: unknown[]
50
+ ) => ({
51
+ id,
52
+ type: 'accordion',
53
+ label: 'Accordion',
54
+ value: '',
55
+ properties: { defaultOpen: true },
56
+ styles,
57
+ order: 0,
58
+ hidden: false,
59
+ blocks: [
60
+ group(`${id}-heading`, 'Accordion Heading', [text(`${id}-title`)]),
61
+ group(`${id}-content`, 'Accordion Content', inner || [text(`${id}-copy`)])
62
+ ]
63
+ });
64
+
65
+ const sectionOf = (root: unknown): Section[] =>
66
+ [
67
+ {
68
+ id: 'sec-1',
69
+ type: 'custom',
70
+ name: 'Section',
71
+ label: 'Section',
72
+ order: 0,
73
+ hidden: false,
74
+ properties: {},
75
+ styles: {},
76
+ blocks: [root]
77
+ }
78
+ ] as unknown as Section[];
79
+
80
+ const renderRoot = (root: unknown) =>
81
+ render(
82
+ <ThemeBlock
83
+ block={root as never}
84
+ placeholderId="p"
85
+ sectionId="sec-1"
86
+ isDesigner={false}
87
+ />
88
+ );
89
+
90
+ /** Every selector the generated stylesheet aims at the given part. */
91
+ const selectorsFor = (css: string, part: string): string[] =>
92
+ css
93
+ .split('}')
94
+ .map((chunk) => chunk.slice(0, chunk.indexOf('{')))
95
+ .filter((selector) => selector.includes(`[data-part="${part}"]`))
96
+ .flatMap((selector) => selector.split(',').map((one) => one.trim()))
97
+ .filter(Boolean);
98
+
99
+ const ownersOf = (root: HTMLElement, selectors: string[]): string[] => {
100
+ const owners = new Set<string>();
101
+
102
+ selectors.forEach((selector) => {
103
+ root.querySelectorAll<HTMLElement>(selector).forEach((element) => {
104
+ owners.add(
105
+ element.closest('[data-block-id]')?.getAttribute('data-block-id') || '?'
106
+ );
107
+ });
108
+ });
109
+
110
+ return Array.from(owners).sort();
111
+ };
112
+
113
+ const partSelector = (blockId: string, part: string): string =>
114
+ buildPartSelectors(`[data-block-id="${blockId}"]`, {
115
+ part,
116
+ state: null,
117
+ styles: {}
118
+ })[0];
119
+
120
+ /**
121
+ * Every `data-part` element in the tree, checked against the rule its OWN
122
+ * block would generate. A non-empty result means the guard deleted a style the
123
+ * block owns — the one way this fix could be worse than the leak it replaces.
124
+ */
125
+ const partsTheirOwnBlockCannotReach = (root: HTMLElement): string[] => {
126
+ const broken: string[] = [];
127
+
128
+ root.querySelectorAll<HTMLElement>('[data-part]').forEach((element) => {
129
+ const owner = element.closest('[data-block-id]');
130
+ if (!owner) return;
131
+
132
+ const ownerId = owner.getAttribute('data-block-id') as string;
133
+ const part = element.getAttribute('data-part') as string;
134
+ const selector = partSelector(ownerId, part);
135
+
136
+ if (selector && !element.matches(selector)) {
137
+ broken.push(`${ownerId}/${part}`);
138
+ }
139
+ });
140
+
141
+ return Array.from(new Set(broken)).sort();
142
+ };
143
+
144
+ describe('the guard reaches every level, not just the first', () => {
145
+ const nest = (styled: string) =>
146
+ accordion(
147
+ 'lvl1',
148
+ styled === 'lvl1' ? { 'header/color': { desktop: '#f00' } } : {},
149
+ [
150
+ accordion(
151
+ 'lvl2',
152
+ styled === 'lvl2' ? { 'header/color': { desktop: '#f00' } } : {},
153
+ [
154
+ accordion(
155
+ 'lvl3',
156
+ styled === 'lvl3' ? { 'header/color': { desktop: '#f00' } } : {}
157
+ )
158
+ ]
159
+ )
160
+ ]
161
+ );
162
+
163
+ it.each(['lvl1', 'lvl2', 'lvl3'])(
164
+ 'a header style on %s reaches that block and no other',
165
+ (level) => {
166
+ const css = generateThemeCSS(sectionOf(nest(level)));
167
+ const { container } = renderRoot(nest('none'));
168
+
169
+ // Premise: three blocks really do name a header the same way.
170
+ expect(container.querySelectorAll('[data-part="header"]')).toHaveLength(3);
171
+ expect(ownersOf(container, selectorsFor(css, 'header'))).toEqual([level]);
172
+ }
173
+ );
174
+
175
+ it('holds on every breakpoint, not only the one that was tested', () => {
176
+ const styled = accordion(
177
+ 'outer',
178
+ { 'header/color': { desktop: '#f00', mobile: '#0f0' } },
179
+ [accordion('inner')]
180
+ );
181
+
182
+ ['desktop', 'mobile', undefined].forEach((breakpoint) => {
183
+ const css = generateThemeCSS(sectionOf(styled), breakpoint);
184
+ const { container, unmount } = renderRoot(
185
+ accordion('outer', {}, [accordion('inner')])
186
+ );
187
+ const selectors = selectorsFor(css, 'header');
188
+
189
+ expect(selectors.length).toBeGreaterThan(0);
190
+ expect(ownersOf(container, selectors)).toEqual(['outer']);
191
+ unmount();
192
+ });
193
+ });
194
+ });
195
+
196
+ describe('the guard never costs a block a part it owns', () => {
197
+ // The breadth case — one tree built from many part-stamped renderers — lands
198
+ // with the renderer vocabulary itself. Only a handful of renderers name their
199
+ // internals at this point, so asserting a wide sweep here would either be
200
+ // vacuous or force the whole block catalogue into this change.
201
+
202
+ it('across repeated rows, which share one style identity', () => {
203
+ const expanded = buildIteratorBlock({
204
+ iteratorBlock: {
205
+ id: 'it-1',
206
+ type: 'group',
207
+ label: 'Repeat',
208
+ isIterator: true,
209
+ blocks: [accordion('tpl')]
210
+ } as never,
211
+ items: [{ a: 1 }, { a: 2 }, { a: 3 }]
212
+ });
213
+
214
+ const { container } = renderRoot(
215
+ accordion('shell', {}, (expanded.blocks || []) as unknown[])
216
+ );
217
+
218
+ // One authored rule, three rows: the clones keep the template's identity,
219
+ // so the rule has to keep matching all of them.
220
+ expect(container.querySelectorAll(partSelector('tpl', 'header'))).toHaveLength(3);
221
+ expect(container.querySelectorAll(partSelector('shell', 'header'))).toHaveLength(1);
222
+ expect(partsTheirOwnBlockCannotReach(container)).toEqual([]);
223
+ });
224
+
225
+ it('across a repeat inside a repeat', () => {
226
+ const inner = buildIteratorBlock({
227
+ iteratorBlock: {
228
+ id: 'it-inner',
229
+ type: 'group',
230
+ label: 'Repeat',
231
+ isIterator: true,
232
+ blocks: [accordion('tpl-inner')]
233
+ } as never,
234
+ items: [{ a: 1 }, { a: 2 }]
235
+ });
236
+ const outer = buildIteratorBlock({
237
+ iteratorBlock: {
238
+ id: 'it-outer',
239
+ type: 'group',
240
+ label: 'Repeat',
241
+ isIterator: true,
242
+ blocks: [accordion('tpl-outer', {}, (inner.blocks || []) as unknown[])]
243
+ } as never,
244
+ items: [{ a: 1 }, { a: 2 }]
245
+ });
246
+
247
+ const { container } = renderRoot(
248
+ accordion('shell', {}, (outer.blocks || []) as unknown[])
249
+ );
250
+
251
+ expect(container.querySelectorAll(partSelector('tpl-outer', 'header'))).toHaveLength(2);
252
+ expect(container.querySelectorAll(partSelector('tpl-inner', 'header'))).toHaveLength(4);
253
+ expect(partsTheirOwnBlockCannotReach(container)).toEqual([]);
254
+ });
255
+ });
256
+
257
+ describe('a composition that names no part', () => {
258
+ it('is generated without a guard anywhere in it', () => {
259
+ // The feature is off until a designer types a part key; the stylesheet a
260
+ // published composition already ships must come out exactly as before.
261
+ const css = generateThemeCSS(
262
+ sectionOf(
263
+ accordion(
264
+ 'outer',
265
+ { backgroundColor: { desktop: '#fff' }, padding: { desktop: '8px' } },
266
+ [accordion('inner')]
267
+ )
268
+ )
269
+ );
270
+
271
+ expect(css).toContain('[data-block-id="outer"]');
272
+ expect(css).not.toContain(':where(');
273
+ expect(css).not.toContain(':not(');
274
+ });
275
+ });
276
+
277
+ describe('a block id that did not come from us', () => {
278
+ it('stays inside the quoted value on both copies of the selector', () => {
279
+ // The guard repeats the block selector, so a crafted id gets two chances
280
+ // to break out of the quoted value instead of one.
281
+ const css = generateThemeCSS(
282
+ sectionOf({
283
+ ...accordion('safe', { 'header/color': { desktop: '#f00' } }),
284
+ styleSourceId: 'a"] , * { color: red } [x="'
285
+ })
286
+ );
287
+
288
+ // Whatever escaping the block selector received, the guard's copy of it is
289
+ // the identical string — so the guard cannot be the weaker of the two and
290
+ // the id gets no second chance at breaking out.
291
+ const blockSelector = css.slice(0, css.indexOf(' [data-part='));
292
+
293
+ expect(blockSelector).toContain('\\"');
294
+ expect(css).toContain(
295
+ `:not(:where(${blockSelector} [data-block-id] *))`
296
+ );
297
+ });
298
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The breakpoints, and the two lines between them.
3
+ *
4
+ * There used to be one line. `1023px` was written out as a literal in the CSS
5
+ * generator, in the custom-CSS hatch and in six block renderers that emit their
6
+ * own scoped `<style>`; the number agreeing everywhere was a matter of nobody
7
+ * mistyping it. It lives here now, and the media-query strings are built from
8
+ * it rather than repeated.
9
+ *
10
+ * Where the second line sits was not invented. Every brand that ships a
11
+ * Tailwind config uses the same two stops — superstep_zero, whites-pz,
12
+ * occasion_next, lacostetr_next, minycenter-next-pz and next-barcin all
13
+ * declare `md: 768px` and `lg: 1024px`, and every hand-written tablet rule in
14
+ * those repos is written against one of them. The editor's own
15
+ * `DEFAULT_BREAKPOINTS` already listed a 768px tablet it had no way to author,
16
+ * and `posts-slider-section` already carried a `{ min: 768, max: 1023 }` band.
17
+ * So: desktop at 1024 and up, tablet 768-1023, mobile 767 and down.
18
+ *
19
+ * ## What a value at one breakpoint means for the others
20
+ *
21
+ * Desktop-first, because that is what the existing output already is: the
22
+ * desktop value is the unconditional rule and narrower breakpoints override it
23
+ * inside a `max-width` query. A third breakpoint does not get to introduce a
24
+ * second convention.
25
+ *
26
+ * `mobile` keeps the meaning it has always had — *narrower than desktop*,
27
+ * everything below 1024 — and `tablet` is a narrower override layered on top of
28
+ * it inside its own band. Read as a fall-through chain:
29
+ *
30
+ * desktop -> (authored desktop)
31
+ * mobile -> mobile ?? desktop
32
+ * tablet -> tablet ?? mobile ?? desktop
33
+ *
34
+ * which is exactly what the emitted cascade produces at each width, given the
35
+ * query order in `generateElementCSS`. That equivalence is the whole point: the
36
+ * renderer, the editor canvas and the stylesheet cannot disagree because they
37
+ * are three readings of the same chain.
38
+ *
39
+ * It is also the only choice that leaves today's compositions alone. Every
40
+ * published composition authors desktop and mobile and nothing else, and on
41
+ * those, tablet resolves to the mobile value and no tablet rule is emitted —
42
+ * byte for byte the stylesheet it produced before.
43
+ */
44
+
45
+ /** Narrowest width that is still "desktop". The existing line, unmoved. */
46
+ export const DESKTOP_MIN_WIDTH = 1024;
47
+
48
+ /** Narrowest width that is still "tablet". Below this is a phone. */
49
+ export const TABLET_MIN_WIDTH = 768;
50
+
51
+ export type BreakpointName = 'desktop' | 'tablet' | 'mobile';
52
+
53
+ /** Widest first, which is also authoring order in the editor. */
54
+ export const BREAKPOINTS: BreakpointName[] = ['desktop', 'tablet', 'mobile'];
55
+
56
+ /**
57
+ * Where a breakpoint looks when it has nothing authored of its own. Absence
58
+ * falls through; it never resolves to an empty value.
59
+ *
60
+ * `desktop` and `mobile` are absent from this map on purpose — they behave
61
+ * exactly as they did before this file existed (`mobile` falls straight back to
62
+ * `desktop`, which the readers do unconditionally).
63
+ */
64
+ export const BREAKPOINT_FALLBACKS: Record<string, BreakpointName[]> = {
65
+ tablet: ['mobile']
66
+ };
67
+
68
+ /**
69
+ * The mobile query, unchanged and deliberately still `max-width: 1023px`
70
+ * rather than being narrowed to 767. Narrowing it would hand the 768-1023 band
71
+ * back to the desktop rule, which is a different-looking tablet on every
72
+ * composition already published.
73
+ */
74
+ export const MOBILE_MEDIA_QUERY = `@media (max-width: ${
75
+ DESKTOP_MIN_WIDTH - 1
76
+ }px)`;
77
+
78
+ /**
79
+ * The tablet query. Emitted *after* the mobile query and at equal specificity,
80
+ * so within 768-1023 it is source order that makes tablet win — see
81
+ * `generateElementCSS`, and `breakpoint-cascade.test.ts` which asserts the
82
+ * order rather than trusting it.
83
+ */
84
+ export const TABLET_MEDIA_QUERY = `@media (min-width: ${TABLET_MIN_WIDTH}px) and (max-width: ${
85
+ DESKTOP_MIN_WIDTH - 1
86
+ }px)`;
87
+
88
+ /**
89
+ * The same two queries without the spaces, for the scoped `<style>` a handful
90
+ * of block renderers inline next to their markup. They were written compact and
91
+ * are asserted compact by tests that pin those blocks' HTML.
92
+ */
93
+ export const MOBILE_MEDIA_QUERY_COMPACT = `@media (max-width:${
94
+ DESKTOP_MIN_WIDTH - 1
95
+ }px)`;
96
+
97
+ export const TABLET_MEDIA_QUERY_COMPACT = `@media (min-width:${TABLET_MIN_WIDTH}px) and (max-width:${
98
+ DESKTOP_MIN_WIDTH - 1
99
+ }px)`;
100
+
101
+ /** The breakpoint a viewport of this width is being read at. */
102
+ export const breakpointForWidth = (width: number): BreakpointName => {
103
+ if (width >= DESKTOP_MIN_WIDTH) return 'desktop';
104
+ if (width >= TABLET_MIN_WIDTH) return 'tablet';
105
+ return 'mobile';
106
+ };
107
+
108
+ /**
109
+ * "Narrower than desktop" — the question the code that predates the tablet was
110
+ * actually asking whenever it compared against `'mobile'`. Answering it with a
111
+ * predicate rather than an equality check is what keeps those call sites
112
+ * behaving at 768-1023 exactly as they did when `'mobile'` was the only other
113
+ * value there was.
114
+ */
115
+ export const isNarrowBreakpoint = (breakpoint?: string): boolean =>
116
+ breakpoint === 'mobile' || breakpoint === 'tablet';
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The escape hatch.
3
+ *
4
+ * Named parts and state controls cover the cases we anticipated. They will
5
+ * never cover all of them — a brand will always turn up wanting the third
6
+ * facet chip on mobile to wrap differently. Rather than grow the descriptor
7
+ * list forever, a block can carry raw CSS scoped to itself, where `&` is the
8
+ * block and `data-part` names its internals:
9
+ *
10
+ * & [data-part="dropdown"] { border-radius: 0; }
11
+ * & [data-part="option"][data-state~="selected"] { outline: 2px solid; }
12
+ *
13
+ * The block selector is prepended to anything that does not already mention
14
+ * `&`, so a designer cannot accidentally restyle the rest of the page. That
15
+ * containment is the point: this is a scoped hatch, not a stylesheet.
16
+ */
17
+
18
+ import { MOBILE_MEDIA_QUERY, TABLET_MEDIA_QUERY } from './breakpoints';
19
+
20
+ /** Nothing here is a legitimate need in a block's scoped CSS, and each is a way out of it. */
21
+ const FORBIDDEN = [
22
+ /@import\b/i,
23
+ /@charset\b/i,
24
+ /javascript\s*:/i,
25
+ /expression\s*\(/i,
26
+ /behavior\s*:/i,
27
+ /-moz-binding/i,
28
+ /<\s*\/?\s*style/i,
29
+ /<\s*script/i
30
+ ];
31
+
32
+ const stripComments = (css: string): string =>
33
+ css.replace(/\/\*[\s\S]*?\*\//g, '');
34
+
35
+ const bracesBalance = (css: string): boolean => {
36
+ let depth = 0;
37
+
38
+ for (const char of css) {
39
+ if (char === '{') depth += 1;
40
+ if (char === '}') {
41
+ depth -= 1;
42
+ // A closing brace with nothing open means the author has already left
43
+ // their own block and is styling the page.
44
+ if (depth < 0) return false;
45
+ }
46
+ }
47
+
48
+ return depth === 0;
49
+ };
50
+
51
+ /**
52
+ * Splits top-level rules so each selector list can be scoped individually.
53
+ * At-rules that carry a body (`@media`, `@supports`, `@container`) keep their
54
+ * body and are scoped from the inside.
55
+ */
56
+ const splitTopLevelRules = (css: string): string[] => {
57
+ const rules: string[] = [];
58
+ let depth = 0;
59
+ let start = 0;
60
+
61
+ for (let i = 0; i < css.length; i++) {
62
+ const char = css[i];
63
+ if (char === '{') depth += 1;
64
+ if (char === '}') {
65
+ depth -= 1;
66
+ if (depth === 0) {
67
+ rules.push(css.slice(start, i + 1));
68
+ start = i + 1;
69
+ }
70
+ }
71
+ }
72
+
73
+ const tail = css.slice(start).trim();
74
+ if (tail) rules.push(tail);
75
+
76
+ return rules.map(rule => rule.trim()).filter(Boolean);
77
+ };
78
+
79
+ const scopeSelectorList = (selectors: string, scope: string): string =>
80
+ selectors
81
+ .split(',')
82
+ .map(selector => selector.trim())
83
+ .filter(Boolean)
84
+ .map(selector =>
85
+ selector.includes('&')
86
+ ? selector.split('&').join(scope)
87
+ : `${scope} ${selector}`
88
+ )
89
+ .join(', ');
90
+
91
+ const scopeRule = (rule: string, scope: string): string => {
92
+ const open = rule.indexOf('{');
93
+ if (open === -1) return '';
94
+
95
+ const prelude = rule.slice(0, open).trim();
96
+ const body = rule.slice(open + 1, rule.lastIndexOf('}'));
97
+
98
+ if (prelude.startsWith('@')) {
99
+ // `@keyframes` and `@font-face` have no selectors to scope — their bodies
100
+ // are keyframe stops and descriptors. Scoping them turned a working
101
+ // animation into a dead at-rule, which is why a spinner could not be
102
+ // authored at all.
103
+ if (/^@(keyframes|-\w+-keyframes|font-face|property)\b/i.test(prelude)) {
104
+ return rule;
105
+ }
106
+
107
+ // Conditional group rules: scope what is inside, keep the condition.
108
+ const inner = splitTopLevelRules(body)
109
+ .map(innerRule => scopeRule(innerRule, scope))
110
+ .filter(Boolean)
111
+ .join('\n');
112
+
113
+ return inner ? `${prelude} {\n${inner}\n}` : '';
114
+ }
115
+
116
+ return `${scopeSelectorList(prelude, scope)} {${body}}`;
117
+ };
118
+
119
+ /**
120
+ * Returns CSS scoped to `scope`, or an empty string when the input is unsafe.
121
+ * Silently dropping beats emitting something the author did not intend.
122
+ */
123
+ export const scopeCustomCss = (raw: unknown, scope: string): string => {
124
+ if (typeof raw !== 'string') return '';
125
+
126
+ const css = stripComments(raw).trim();
127
+ if (!css) return '';
128
+
129
+ if (FORBIDDEN.some(pattern => pattern.test(css))) return '';
130
+ if (!bracesBalance(css)) return '';
131
+
132
+ // Bare declarations ("color: red") are the common case for "just this
133
+ // block", so they get wrapped rather than rejected.
134
+ if (!css.includes('{')) {
135
+ return `${scope} {\n${css}\n}\n`;
136
+ }
137
+
138
+ return (
139
+ splitTopLevelRules(css)
140
+ .map(rule => scopeRule(rule, scope))
141
+ .filter(Boolean)
142
+ .join('\n') + '\n'
143
+ );
144
+ };
145
+
146
+ /** Reads one breakpoint's custom CSS off a block. */
147
+ export const readCustomCss = (
148
+ properties: Record<string, unknown> | undefined | null,
149
+ targetBreakpoint?: string
150
+ ): string => {
151
+ const entry = properties?.customCss ?? properties?.['custom-css'];
152
+ if (entry === undefined || entry === null) return '';
153
+
154
+ if (typeof entry === 'string') return entry;
155
+
156
+ if (typeof entry === 'object') {
157
+ const responsive = entry as {
158
+ desktop?: unknown;
159
+ tablet?: unknown;
160
+ mobile?: unknown;
161
+ };
162
+ // The same fall-through the styles use: a tablet with nothing of its own
163
+ // shows what the mobile bucket puts on screen at that width, not desktop.
164
+ const value =
165
+ targetBreakpoint === 'mobile'
166
+ ? responsive.mobile ?? responsive.desktop
167
+ : targetBreakpoint === 'tablet'
168
+ ? responsive.tablet ?? responsive.mobile ?? responsive.desktop
169
+ : responsive.desktop;
170
+ return typeof value === 'string' ? value : '';
171
+ }
172
+
173
+ return '';
174
+ };
175
+
176
+ /**
177
+ * All of a block's custom CSS, scoped.
178
+ *
179
+ * On the storefront there is no target breakpoint — the same stylesheet has
180
+ * to serve all of them — so reading a single bucket silently dropped
181
+ * everything a designer wrote for a narrower viewport. Each is emitted inside
182
+ * the same media query the rest of the generator uses, tablet after mobile so
183
+ * source order settles the 768-1023 band. In the designer a breakpoint IS
184
+ * given, and only that bucket is emitted, because the canvas is already
185
+ * showing one viewport.
186
+ */
187
+ export const buildCustomCss = (
188
+ properties: Record<string, unknown> | undefined | null,
189
+ scope: string,
190
+ targetBreakpoint?: string
191
+ ): string => {
192
+ if (targetBreakpoint) {
193
+ return scopeCustomCss(readCustomCss(properties, targetBreakpoint), scope);
194
+ }
195
+
196
+ const desktop = scopeCustomCss(readCustomCss(properties), scope);
197
+
198
+ const entry = properties?.customCss ?? properties?.['custom-css'];
199
+ const responsive =
200
+ entry && typeof entry === 'object'
201
+ ? (entry as { desktop?: unknown; tablet?: unknown; mobile?: unknown })
202
+ : null;
203
+ const mobileRaw = responsive ? responsive.mobile : null;
204
+ const tabletRaw = responsive ? responsive.tablet : null;
205
+
206
+ // Only a value that differs from desktop is worth a second rule.
207
+ const desktopRaw = responsive ? responsive.desktop : entry;
208
+
209
+ let css = desktop;
210
+
211
+ if (typeof mobileRaw === 'string' && mobileRaw.trim()) {
212
+ if (mobileRaw !== desktopRaw) {
213
+ const mobile = scopeCustomCss(mobileRaw, scope);
214
+ if (mobile) css += `${MOBILE_MEDIA_QUERY} {\n${mobile}}\n`;
215
+ }
216
+ }
217
+
218
+ // A tablet rule earns its bytes only against what the band would already
219
+ // show: the mobile block when there is one, otherwise desktop.
220
+ const inheritedRaw =
221
+ typeof mobileRaw === 'string' && mobileRaw.trim() ? mobileRaw : desktopRaw;
222
+
223
+ if (
224
+ typeof tabletRaw === 'string' &&
225
+ tabletRaw.trim() &&
226
+ tabletRaw !== inheritedRaw
227
+ ) {
228
+ const tablet = scopeCustomCss(tabletRaw, scope);
229
+ if (tablet) css += `${TABLET_MEDIA_QUERY} {\n${tablet}}\n`;
230
+ }
231
+
232
+ return css;
233
+ };