@akinon/pz-theme 2.0.84-rc.0 → 2.0.85-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.
@@ -3,6 +3,7 @@
3
3
  import React from 'react';
4
4
 
5
5
  import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
6
+ import { buildModalChromeCss } from './modal-chrome-css';
6
7
  import {
7
8
  CentredModalSurface,
8
9
  LayeredOverlaySurface,
@@ -120,6 +121,13 @@ export const ModalPreviewHost = ({
120
121
  */
121
122
  const presentation = resolveModalPresentation(modal, breakpoint);
122
123
 
124
+ /**
125
+ * The designer's chrome styles. Always pinned to the editor's chosen
126
+ * breakpoint: this host only ever runs inside the canvas, where the iframe's
127
+ * own width answers a different question than the viewport buttons do.
128
+ */
129
+ const chromeCss = buildModalChromeCss(modal, breakpoint);
130
+
123
131
  // Every dismissal is inert: the designer has to be able to click the close
124
132
  // button and the backdrop they are colouring without the surface vanishing.
125
133
  const noop = () => undefined;
@@ -132,6 +140,8 @@ export const ModalPreviewHost = ({
132
140
  right={chrome.closeRight}
133
141
  size={chrome.closeSize}
134
142
  elevate
143
+ text={chrome.closeText}
144
+ textSide={chrome.closeTextSide}
135
145
  />
136
146
  ) : null;
137
147
 
@@ -154,6 +164,7 @@ export const ModalPreviewHost = ({
154
164
  panelPadding={chrome.padding}
155
165
  panelStretch={chrome.stretchContent}
156
166
  topInset={chrome.topInset}
167
+ chromeCss={chromeCss}
157
168
  header={header}
158
169
  >
159
170
  {composition(modal.slug)}
@@ -179,6 +190,7 @@ export const ModalPreviewHost = ({
179
190
  sheetHeight={chrome.sheetHeight}
180
191
  showSheetHandle={chrome.showSheetHandle}
181
192
  topInset={chrome.topInset}
193
+ chromeCss={chromeCss}
182
194
  header={header}
183
195
  >
184
196
  {composition(modal.slug)}
@@ -140,6 +140,18 @@ export interface ModalChrome {
140
140
  * turns on.
141
141
  */
142
142
  stretchContent?: boolean;
143
+ /**
144
+ * Visible text beside the built-in close button, and which side of the icon
145
+ * it sits on.
146
+ *
147
+ * `closeLabel` stays the ACCESSIBLE name — it is what a screen reader says,
148
+ * and it has to keep saying it whether or not sighted text exists. This is
149
+ * markup rather than a style, which is why no CSS control could supply it:
150
+ * absent, the button renders the icon alone, exactly as every modal
151
+ * published before this does.
152
+ */
153
+ closeText?: string;
154
+ closeTextSide?: 'before' | 'after';
143
155
  }
144
156
 
145
157
  /**
@@ -181,6 +193,19 @@ export interface ThemeModal {
181
193
  Record<'tablet' | 'mobile', ModalPresentation>
182
194
  >;
183
195
  chrome: ModalChrome;
196
+ /**
197
+ * The designer's own styles for the modal's CHROME — the panel, the backdrop,
198
+ * the close button, its text and a sheet's grab bar.
199
+ *
200
+ * Same shape as a block's `styles`, so the editor drives it with the control
201
+ * vocabulary it already has: `styles[cssKey][breakpoint]`. The key grammar is
202
+ * the one thing to keep straight — a BARE css key targets the panel, and a
203
+ * `part/property` (or `part/state/property`) key targets a named chrome part.
204
+ * `buildModalChromeCss` in `modal-chrome-css.ts` is what turns this into CSS,
205
+ * and it emits `!important` because the elements it dresses carry the
206
+ * renderer's own inline defaults.
207
+ */
208
+ styles?: Record<string, unknown>;
184
209
  /**
185
210
  * Chrome values that differ at a narrower breakpoint.
186
211
  *
@@ -572,7 +597,12 @@ const readChrome = (value: unknown, name: string): ModalChrome => {
572
597
  closeRight: asChromeLength(raw.closeRight, 200),
573
598
  // A tap target below ~24px is hard to hit and above ~64px is not an icon.
574
599
  closeSize: asChromeLength(raw.closeSize, 64),
575
- stretchContent: asToggle(raw.stretchContent, false)
600
+ stretchContent: asToggle(raw.stretchContent, false),
601
+ // Absence-preserving, per the doctrine above: a modal that never authored
602
+ // a close text must not come out of here claiming an empty one, and the
603
+ // side only means something once the text exists.
604
+ closeText: asString(raw.closeText) || undefined,
605
+ closeTextSide: raw.closeTextSide === 'before' ? 'before' : undefined
576
606
  };
577
607
  };
578
608
 
@@ -620,6 +650,14 @@ export const readThemeModal = (value: unknown): ThemeModal | null => {
620
650
  visibilityRules:
621
651
  raw.visibilityRules && typeof raw.visibilityRules === 'object'
622
652
  ? (raw.visibilityRules as VisibilityRules)
653
+ : undefined,
654
+ // The one gate every chrome style passes through. Left out, the editor
655
+ // saves, the panel looks alive and nothing renders — in the canvas OR in
656
+ // production. `undefined` rather than `{}` so "never authored" stays
657
+ // distinguishable from "authored empty".
658
+ styles:
659
+ raw.styles && typeof raw.styles === 'object' && !Array.isArray(raw.styles)
660
+ ? (raw.styles as Record<string, unknown>)
623
661
  : undefined
624
662
  };
625
663
  };
@@ -312,6 +312,17 @@ export interface OverlayCloseButtonProps {
312
312
  * hosts ask.
313
313
  */
314
314
  elevate?: boolean;
315
+ /**
316
+ * Visible text beside the glyph, and which side of it that text sits on.
317
+ *
318
+ * `label` stays the accessible name whether or not this is set — a screen
319
+ * reader must still be told what the button does, and the two can legitimately
320
+ * differ ("Close" vs. "Skip for now"). Absent leaves the button's markup and
321
+ * style object byte-identical to what it has always rendered, which is what
322
+ * `panel-legacy-golden.json` pins for the legacy size-guide panel.
323
+ */
324
+ text?: string;
325
+ textSide?: 'before' | 'after';
315
326
  /** Legacy marker attribute, e.g. `{ 'data-size-guide-close': '' }`. */
316
327
  markerAttrs?: Record<string, string>;
317
328
  }
@@ -336,6 +347,8 @@ export const OverlayCloseButton = ({
336
347
  right,
337
348
  size,
338
349
  elevate,
350
+ text,
351
+ textSide,
339
352
  markerAttrs
340
353
  }: OverlayCloseButtonProps) => (
341
354
  <button
@@ -365,7 +378,20 @@ export const OverlayCloseButton = ({
365
378
  : { padding: '8px', margin: '-8px', lineHeight: 0 }),
366
379
  // Only has to beat static content, which is what the composition's
367
380
  // blocks are; the panel's own layer sits at 1000 above the page.
368
- ...(elevate ? { zIndex: 2 } : null)
381
+ ...(elevate ? { zIndex: 2 } : null),
382
+ // Nothing at all without a text: the glyph-only button is pinned byte
383
+ // for byte by the legacy golden fixture.
384
+ ...(text
385
+ ? {
386
+ display: 'inline-flex',
387
+ alignItems: 'center',
388
+ gap: '8px',
389
+ flexDirection:
390
+ textSide === 'before'
391
+ ? ('row-reverse' as const)
392
+ : ('row' as const)
393
+ }
394
+ : null)
369
395
  }}
370
396
  >
371
397
  <svg
@@ -379,6 +405,7 @@ export const OverlayCloseButton = ({
379
405
  >
380
406
  <path d="M6 6l12 12M18 6L6 18" />
381
407
  </svg>
408
+ {text ? <span {...partAttrs('close-text')}>{text}</span> : null}
382
409
  </button>
383
410
  );
384
411
 
@@ -440,6 +467,15 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
440
467
  * which is what every published modal does.
441
468
  */
442
469
  topInset?: number | string;
470
+ /**
471
+ * The designer's chrome styles for THIS modal, already compiled to CSS by
472
+ * `buildModalChromeCss`. Rendered as a `<style>` inside the surface, next to
473
+ * the stretch rules, so it travels with the panel it dresses.
474
+ *
475
+ * A prop the panel block never passes, deliberately: an empty string must
476
+ * render no tag at all, or `panel-legacy-golden.json` moves.
477
+ */
478
+ chromeCss?: string;
443
479
  /** Rendered inside the panel, above the authored content. */
444
480
  header?: React.ReactNode;
445
481
  children: React.ReactNode;
@@ -492,6 +528,7 @@ export const CentredModalSurface = ({
492
528
  panelPadding,
493
529
  panelStretch,
494
530
  topInset,
531
+ chromeCss,
495
532
  header,
496
533
  children,
497
534
  markers
@@ -552,6 +589,9 @@ export const CentredModalSurface = ({
552
589
  }}
553
590
  >
554
591
  {panelStretch && <PanelStretchStyles />}
592
+ {chromeCss ? (
593
+ <style dangerouslySetInnerHTML={{ __html: chromeCss }} />
594
+ ) : null}
555
595
  {header}
556
596
  {children}
557
597
  </div>
@@ -584,6 +624,7 @@ export const LayeredOverlaySurface = ({
584
624
  panelStretch,
585
625
  showSheetHandle,
586
626
  topInset,
627
+ chromeCss,
587
628
  header,
588
629
  children,
589
630
  markers
@@ -637,6 +678,9 @@ export const LayeredOverlaySurface = ({
637
678
  }}
638
679
  >
639
680
  {panelStretch && <PanelStretchStyles />}
681
+ {chromeCss ? (
682
+ <style dangerouslySetInnerHTML={{ __html: chromeCss }} />
683
+ ) : null}
640
684
  {presentation === 'sheet' && showSheetHandle !== false && (
641
685
  <OverlaySheetHandle markerAttrs={markers?.handle} />
642
686
  )}
@@ -12,6 +12,7 @@ import {
12
12
  resolveModalChrome,
13
13
  resolveModalPresentation
14
14
  } from './modal-registry';
15
+ import { buildModalChromeCss } from './modal-chrome-css';
15
16
  import {
16
17
  CentredModalSurface,
17
18
  LayeredOverlaySurface,
@@ -121,6 +122,18 @@ const ThemeModalMount = ({
121
122
  const presentation = resolveModalPresentation(modal, breakpoint);
122
123
  const isCentred = presentation === 'modal';
123
124
 
125
+ /**
126
+ * The designer's chrome styles, compiled here so both surfaces get the same
127
+ * string. In the canvas it is FORCED to one breakpoint — the iframe's width
128
+ * is not the device's, so real media queries would answer the wrong
129
+ * question; on a storefront the queries are emitted, which also spares the
130
+ * server render a flash while `useOverlayBreakpoint` starts at `desktop`.
131
+ */
132
+ const chromeCss = buildModalChromeCss(
133
+ modal,
134
+ isDesigner ? breakpoint : undefined
135
+ );
136
+
124
137
  /**
125
138
  * In the canvas the overlay is fully inert: it never steals focus into the
126
139
  * page the designer is styling, never freezes it, never claims `aria-modal`
@@ -151,6 +164,8 @@ const ThemeModalMount = ({
151
164
  right={chrome.closeRight}
152
165
  size={chrome.closeSize}
153
166
  elevate
167
+ text={chrome.closeText}
168
+ textSide={chrome.closeTextSide}
154
169
  />
155
170
  ) : null;
156
171
 
@@ -169,6 +184,7 @@ const ThemeModalMount = ({
169
184
  panelPadding={chrome.padding}
170
185
  panelStretch={chrome.stretchContent}
171
186
  topInset={chrome.topInset}
187
+ chromeCss={chromeCss}
172
188
  header={header}
173
189
  >
174
190
  {children}
@@ -191,6 +207,7 @@ const ThemeModalMount = ({
191
207
  topInset={chrome.topInset}
192
208
  panelPadding={chrome.padding}
193
209
  panelStretch={chrome.stretchContent}
210
+ chromeCss={chromeCss}
194
211
  header={header}
195
212
  >
196
213
  {children}
@@ -0,0 +1,334 @@
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 { buildInnerRootSelectors, buildPartSelectors } from '../part-styles';
6
+ import { buildIteratorBlock } from '../iterator-utils';
7
+ import type { Section } from '../../theme-section';
8
+
9
+ /**
10
+ * The routed root rule, confined to the block that owns the root.
11
+ *
12
+ * Three renderers draw their own root and mark it `data-part="root"` (text,
13
+ * link, button — see INNER_ROOT_BLOCK_TYPES), and the sheet routes the block's
14
+ * rule to that element. The routed selector is a DESCENDANT one, so on a card
15
+ * built the way every collection grid is built — a link wrapping a label — the
16
+ * link's rule also matched the label's root.
17
+ *
18
+ * It was the worst possible part name to leak. `root` is not an internal a
19
+ * designer occasionally reaches for: those three renderers mark one every time,
20
+ * and so do several functional blocks that are not in the routed set at all
21
+ * (auth-error, auth-step-title, basket-summary, checkout-steps,
22
+ * checkout-summary, order-success), so the leak fired on the most ordinary
23
+ * composition there is and its victims were not limited to the routed types.
24
+ * The keys a card carries are layout keys, which is what made it loud: the
25
+ * label inherited the card's `aspect-ratio: 1/1` and `flex: 1 1 0`, went square
26
+ * at the card's full width, and left the image beside it a 4px sliver — plus a
27
+ * second border and radius drawn inside the first.
28
+ *
29
+ * The fix is the guard that already confines every other part rule.
30
+ */
31
+
32
+ const text = (id: string, styles: Record<string, unknown> = {}) => ({
33
+ id,
34
+ type: 'text',
35
+ label: 'Label',
36
+ value: '<p>SOLID</p>',
37
+ properties: {},
38
+ styles,
39
+ order: 0,
40
+ hidden: false
41
+ });
42
+
43
+ const group = (id: string, children: unknown[]) => ({
44
+ id,
45
+ type: 'group',
46
+ label: 'Group',
47
+ properties: {},
48
+ styles: {},
49
+ order: 0,
50
+ hidden: false,
51
+ blocks: children
52
+ });
53
+
54
+ const link = (
55
+ id: string,
56
+ styles: Record<string, unknown> = {},
57
+ children: unknown[] = []
58
+ ) => ({
59
+ id,
60
+ type: 'link',
61
+ label: 'Card',
62
+ value: '',
63
+ properties: { href: '/collections' },
64
+ styles,
65
+ order: 0,
66
+ hidden: false,
67
+ blocks: children
68
+ });
69
+
70
+ const button = (id: string, styles: Record<string, unknown> = {}) => ({
71
+ id,
72
+ type: 'button',
73
+ label: 'CTA',
74
+ value: 'Shop',
75
+ // `tag: 'button'` so the renderer draws a <button>; its default is an <a>,
76
+ // which inside the card's own <a> is markup no theme can produce.
77
+ properties: { text: 'Shop', tag: 'button' },
78
+ styles,
79
+ order: 0,
80
+ hidden: false
81
+ });
82
+
83
+ const image = (id: string) => ({
84
+ id,
85
+ type: 'image',
86
+ label: 'Image',
87
+ value: '',
88
+ properties: { src: '/a.png' },
89
+ styles: { flex: { desktop: '1 1 0' } },
90
+ order: 0,
91
+ hidden: false
92
+ });
93
+
94
+ /** The card that broke: a link, a label inside a group, an image beside it. */
95
+ const card = (cardStyles: Record<string, unknown>, extra: unknown[] = []) =>
96
+ link('card', cardStyles, [
97
+ image('media'),
98
+ group('caption', [text('label')]),
99
+ ...extra
100
+ ]);
101
+
102
+ const sectionOf = (root: unknown): Section[] =>
103
+ [
104
+ {
105
+ id: 'sec-1',
106
+ type: 'custom',
107
+ name: 'Section',
108
+ label: 'Section',
109
+ order: 0,
110
+ hidden: false,
111
+ properties: {},
112
+ styles: {},
113
+ blocks: [root]
114
+ }
115
+ ] as unknown as Section[];
116
+
117
+ const renderRoot = (root: unknown) =>
118
+ render(
119
+ <ThemeBlock
120
+ block={root as never}
121
+ placeholderId="p"
122
+ sectionId="sec-1"
123
+ isDesigner={false}
124
+ />
125
+ );
126
+
127
+ /**
128
+ * Every selector the generated stylesheet aims at a `root` part.
129
+ *
130
+ * At-rule preludes are dropped with the chunk they open, so a rule that only
131
+ * exists inside `@media` is not seen here. That is why the breakpoint case
132
+ * below reads the TARGETED output, which `generateElementCSS` emits unwrapped.
133
+ */
134
+ const rootSelectors = (css: string): string[] =>
135
+ css
136
+ .split('}')
137
+ .map((chunk) => chunk.slice(0, chunk.indexOf('{')))
138
+ .filter((selector) => selector.includes('[data-part="root"]'))
139
+ .flatMap((selector) => selector.split(',').map((one) => one.trim()))
140
+ .filter(Boolean);
141
+
142
+ /** Which blocks' roots those selectors actually reach in the rendered card. */
143
+ const ownersOf = (root: HTMLElement, selectors: string[]): string[] => {
144
+ const owners = new Set<string>();
145
+
146
+ selectors.forEach((selector) => {
147
+ root.querySelectorAll<HTMLElement>(selector).forEach((element) => {
148
+ owners.add(
149
+ element.closest('[data-block-id]')?.getAttribute('data-block-id') || '?'
150
+ );
151
+ });
152
+ });
153
+
154
+ return Array.from(owners).sort();
155
+ };
156
+
157
+ const boxStyles = {
158
+ 'aspect-ratio': { desktop: '1/1' },
159
+ flex: { desktop: '1 1 0' },
160
+ 'border-radius': { desktop: '20px' },
161
+ 'border-width': { desktop: '1px' }
162
+ };
163
+
164
+ describe('a card whose link carries the box', () => {
165
+ it('paints the link root and not the label root', () => {
166
+ const css = generateThemeCSS(sectionOf(card(boxStyles)));
167
+ const { container } = renderRoot(card({}));
168
+
169
+ // Premise: both blocks really do mark a root, one inside the other.
170
+ expect(container.querySelectorAll('[data-part="root"]').length).toBe(2);
171
+ expect(ownersOf(container, rootSelectors(css))).toEqual(['card']);
172
+ });
173
+
174
+ it('leaves the label reachable by its own rule', () => {
175
+ // The other way this could go wrong: a guard that also deletes the style
176
+ // the nested block itself declares.
177
+ const styledLabel = generateThemeCSS(
178
+ sectionOf(
179
+ link('card', boxStyles, [
180
+ image('media'),
181
+ group('caption', [text('label', { 'font-size': { desktop: '18px' } })])
182
+ ])
183
+ )
184
+ );
185
+ const { container } = renderRoot(card({}));
186
+
187
+ expect(ownersOf(container, rootSelectors(styledLabel))).toEqual([
188
+ 'card',
189
+ 'label'
190
+ ]);
191
+ });
192
+
193
+ it('confines a hover rule to the link root as well', () => {
194
+ const css = generateThemeCSS(
195
+ sectionOf(card({ ':hover/border-color': { desktop: '#000000' } }))
196
+ );
197
+ const { container } = renderRoot(card({}));
198
+
199
+ // The canvas pins a state by stamping it, which is why every state also
200
+ // matches `[data-state~="…"]` — and it is the only one of the emitted
201
+ // expressions a test can put on an element. Stamped on BOTH roots, so the
202
+ // rule gets its chance to leak.
203
+ container
204
+ .querySelectorAll<HTMLElement>('[data-part="root"]')
205
+ .forEach((element) => element.setAttribute('data-state', 'hover'));
206
+
207
+ const stamped = rootSelectors(css).filter((selector) =>
208
+ selector.includes('[data-state~="hover"]')
209
+ );
210
+
211
+ expect(stamped.length).toBeGreaterThan(0);
212
+ expect(ownersOf(container, stamped)).toEqual(['card']);
213
+ });
214
+
215
+ it('holds on every breakpoint', () => {
216
+ ['desktop', 'mobile', 'tablet'].forEach((breakpoint) => {
217
+ const css = generateThemeCSS(
218
+ sectionOf(
219
+ card({
220
+ 'aspect-ratio': { desktop: '1/1', tablet: '3/4', mobile: '4/5' }
221
+ })
222
+ ),
223
+ breakpoint
224
+ );
225
+ const { container, unmount } = renderRoot(card({}));
226
+ const selectors = rootSelectors(css);
227
+
228
+ // Targeted output is emitted unwrapped, so the value resolved for THIS
229
+ // breakpoint is the one carried by the selectors read here.
230
+ expect(selectors.length).toBeGreaterThan(0);
231
+ expect(ownersOf(container, selectors)).toEqual(['card']);
232
+ unmount();
233
+ });
234
+ });
235
+ });
236
+
237
+ describe('the other shapes that nest a root inside a root', () => {
238
+ /**
239
+ * Only a link nests another block's root among the three routed types: a text
240
+ * renders its copy and a button renders its own label, neither takes children
241
+ * (see button-block.tsx, which draws `{content}`). So a link is the outer
242
+ * block in every shape below — and `<a>` inside `<a>` is not markup a theme
243
+ * can produce, which leaves a button, a text, and the functional blocks named
244
+ * at the top of this file as the roots a card can contain.
245
+ */
246
+ it('a button inside a link', () => {
247
+ const withCta = (styles: Record<string, unknown>) =>
248
+ card(styles, [button('cta')]);
249
+ const css = generateThemeCSS(sectionOf(withCta(boxStyles)));
250
+ const { container } = renderRoot(withCta({}));
251
+
252
+ expect(ownersOf(container, rootSelectors(css))).toEqual(['card']);
253
+ });
254
+
255
+ it('a repeated card, whose clones share one style identity', () => {
256
+ const expanded = buildIteratorBlock({
257
+ iteratorBlock: {
258
+ id: 'it-1',
259
+ type: 'group',
260
+ label: 'Repeat',
261
+ isIterator: true,
262
+ blocks: [card(boxStyles)]
263
+ } as never,
264
+ items: [{ a: 1 }, { a: 2 }, { a: 3 }]
265
+ });
266
+ const composition = group('grid', (expanded.blocks || []) as unknown[]);
267
+
268
+ const css = generateThemeCSS(sectionOf(composition));
269
+ const { container } = renderRoot(composition);
270
+ const [guarded] = buildInnerRootSelectors('[data-block-id="card"]');
271
+
272
+ // One authored rule, three rows: it has to keep matching every clone's own
273
+ // root, and none of their labels.
274
+ expect(container.querySelectorAll(guarded)).toHaveLength(3);
275
+ expect(ownersOf(container, rootSelectors(css))).toEqual(['card']);
276
+ });
277
+
278
+ it('a block whose style identity is not its id', () => {
279
+ const shape = (styles: Record<string, unknown>) => ({
280
+ ...link('instance', styles, [group('caption', [text('label')])]),
281
+ styleSourceId: 'template'
282
+ });
283
+ const css = generateThemeCSS(sectionOf(shape(boxStyles)));
284
+ const { container } = renderRoot(shape({}));
285
+
286
+ // Rule and guard are keyed on the same identity, or the guard would exclude
287
+ // the element the rule is aimed at.
288
+ expect(css).toContain('[data-block-id="template"] [data-part="root"]');
289
+ expect(ownersOf(container, rootSelectors(css))).toEqual(['template']);
290
+ });
291
+
292
+ it('a block type that opts into the routing explicitly', () => {
293
+ // `stylesTargetRoot` overrides the type list, so the flag path has to reach
294
+ // the guarded builder too.
295
+ const css = generateThemeCSS(
296
+ sectionOf({
297
+ ...group('opted-in', [text('label')]),
298
+ styles: boxStyles,
299
+ stylesTargetRoot: true
300
+ })
301
+ );
302
+
303
+ expect(css).toContain(
304
+ '[data-block-id="opted-in"] [data-part="root"]' +
305
+ ':not(:where([data-block-id="opted-in"] [data-block-id] *))'
306
+ );
307
+ });
308
+ });
309
+
310
+ describe('the selector builder', () => {
311
+ it('delegates to the part builder, so root cannot drift from every part', () => {
312
+ // Asserted against the part builder itself rather than a copy of its
313
+ // output: the point of the fix is that `root` is not special-cased.
314
+ ['[data-block-id="c"]', '[data-block-id="a\\"] , * {}"]'].forEach(
315
+ (blockSelector) => {
316
+ expect(buildInnerRootSelectors(blockSelector)).toEqual(
317
+ buildPartSelectors(blockSelector, {
318
+ part: 'root',
319
+ state: null,
320
+ styles: {}
321
+ })
322
+ );
323
+
324
+ expect(buildInnerRootSelectors(blockSelector, 'hover')).toEqual(
325
+ buildPartSelectors(blockSelector, {
326
+ part: 'root',
327
+ state: 'hover',
328
+ styles: {}
329
+ })
330
+ );
331
+ }
332
+ );
333
+ });
334
+ });
@@ -54,7 +54,12 @@ describe('a renderer that draws its own root', () => {
54
54
  sectionWith({ id: 'txt', type: 'text', styles: { padding: '24px' } })
55
55
  );
56
56
 
57
- expect(css).toContain('[data-block-id="txt"] [data-part="root"] {');
57
+ // Guarded, so the rule cannot also paint the root a nested block marks —
58
+ // that containment is covered in inner-root-ownership.test.tsx.
59
+ expect(css).toContain(
60
+ '[data-block-id="txt"] [data-part="root"]' +
61
+ ':not(:where([data-block-id="txt"] [data-block-id] *)) {'
62
+ );
58
63
  // The wrapper must NOT also carry it: that second application is the bug.
59
64
  expect(css).not.toContain('[data-block-id="txt"] {');
60
65
  });
@@ -283,6 +283,12 @@ describe('a composition that names no part', () => {
283
283
  it('is generated without a guard anywhere in it', () => {
284
284
  // The feature is off until a designer types a part key; the stylesheet a
285
285
  // published composition already ships must come out exactly as before.
286
+ //
287
+ // Read the scope literally: it is a composition of blocks whose styles land
288
+ // on the WRAPPER. A text, link or button root rule is routed to
289
+ // `data-part="root"` and therefore carries the guard whether or not a part
290
+ // key was typed — see inner-root-ownership.test.tsx. Accordions are used
291
+ // here precisely because they are not one of those three.
286
292
  const css = generateThemeCSS(
287
293
  sectionOf(
288
294
  accordion(
@@ -174,8 +174,12 @@ describe('the block and section root rules', () => {
174
174
 
175
175
  // The state has to land on the same element the stateless root rule does,
176
176
  // or it would style the transparent designer wrapper the block sits in.
177
+ // Ownership guard included: a routed root rule is a descendant selector,
178
+ // and a nested block marks a root of its own.
177
179
  expect(css).toContain(
178
- `${SLOT} [data-block-id="blk-1"] [data-part="root"] {\n color: #111111;\n}`
180
+ `${SLOT} [data-block-id="blk-1"] [data-part="root"]` +
181
+ `:not(:where([data-block-id="blk-1"] [data-block-id] *))` +
182
+ ` {\n color: #111111;\n}`
179
183
  );
180
184
  });
181
185