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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.46-beta.0
3
+ ## 2.0.46
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd75]
8
- - @akinon/next@2.0.46-beta.0
9
- - @akinon/theme-contract@2.0.46-beta.0
7
+ - fa20bf7: ZERO-4738: Emit part-scoped custom CSS rules from the part style panel
8
+ - @akinon/next@2.0.46
9
+ - @akinon/theme-contract@2.0.46
10
10
 
11
11
  ## 2.0.45
12
12
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
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.46-beta.0",
4
+ "version": "2.0.46",
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.46-beta.0",
11
+ "@akinon/next": "2.0.46",
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.46-beta.0",
16
+ "@akinon/theme-contract": "2.0.46",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.46-beta.0",
23
+ "@akinon/next": "2.0.46",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -179,3 +179,118 @@ describe('custom CSS across breakpoints', () => {
179
179
  expect(css).toContain('animation: pz-spin');
180
180
  });
181
181
  });
182
+
183
+ /**
184
+ * The same hatch, scoped to one part instead of the whole block.
185
+ *
186
+ * A designer working inside a part previously had to climb back out to the
187
+ * block panel and write `& [data-part="x"] { … }` there, which also meant the
188
+ * two panels shared one value. `customCss/<part>` gives the part its own.
189
+ */
190
+ describe('part-scoped custom CSS', () => {
191
+ const PART_SCOPE =
192
+ '[data-block-id="blk-1"] [data-part="dropdown"]:not(:where([data-block-id="blk-1"] [data-block-id] *))';
193
+
194
+ const sectionWithPartCustomCss = (
195
+ properties: Record<string, unknown>
196
+ ): Section[] =>
197
+ [
198
+ {
199
+ id: 'sec-1',
200
+ type: 'custom',
201
+ name: 'Section',
202
+ label: 'Section',
203
+ order: 0,
204
+ hidden: false,
205
+ properties: {},
206
+ styles: {},
207
+ blocks: [
208
+ {
209
+ id: 'blk-1',
210
+ type: 'variant-selector',
211
+ label: 'Variant selector',
212
+ order: 0,
213
+ hidden: false,
214
+ value: '',
215
+ properties,
216
+ styles: {},
217
+ blocks: []
218
+ }
219
+ ]
220
+ }
221
+ ] as unknown as Section[];
222
+
223
+ it('scopes the part hatch to the part, ownership guard included', () => {
224
+ const css = generateThemeCSS(
225
+ sectionWithPartCustomCss({
226
+ 'customCss/dropdown': '& { clip-path: inset(0 0 20% 0); }'
227
+ })
228
+ );
229
+
230
+ expect(css).toContain(`${PART_SCOPE} {`);
231
+ expect(css).toContain('clip-path: inset(0 0 20% 0)');
232
+ });
233
+
234
+ it('wraps a bare declaration in the part scope', () => {
235
+ const css = generateThemeCSS(
236
+ sectionWithPartCustomCss({ 'customCss/dropdown': 'color: red;' })
237
+ );
238
+
239
+ expect(css).toContain(`${PART_SCOPE} {`);
240
+ expect(css).toContain('color: red');
241
+ });
242
+
243
+ it('adds no !important and passes a typed one through', () => {
244
+ // Part CONTROLS are forced !important; the hatch is not, exactly as at
245
+ // block level — typing it is how a designer beats an inline default.
246
+ const css = generateThemeCSS(
247
+ sectionWithPartCustomCss({
248
+ 'customCss/dropdown': '& { color: red; border: 0 !important; }'
249
+ })
250
+ );
251
+
252
+ expect(css).toContain('color: red;');
253
+ expect(css).not.toContain('color: red !important');
254
+ expect(css).toContain('border: 0 !important');
255
+ });
256
+
257
+ it('emits the narrower bucket inside the mobile media query', () => {
258
+ const css = generateThemeCSS(
259
+ sectionWithPartCustomCss({
260
+ 'customCss/dropdown': {
261
+ desktop: '& { gap: 24px; }',
262
+ mobile: '& { gap: 8px; }'
263
+ }
264
+ })
265
+ );
266
+
267
+ expect(css).toContain('gap: 24px');
268
+ expect(css).toContain('@media (max-width: 1023px)');
269
+ expect(css).toContain('gap: 8px');
270
+ });
271
+
272
+ it('drops a part name that could break out of the selector', () => {
273
+ const css = generateThemeCSS(
274
+ sectionWithPartCustomCss({ 'customCss/../evil': '& { color: red; }' })
275
+ );
276
+
277
+ // The sanitiser strips everything but [A-Za-z0-9_-], so the name survives
278
+ // only as `evil` — what must never appear is an unscoped or escaped rule.
279
+ expect(css).not.toContain('../evil');
280
+ expect(css).not.toContain('"] [data-part=".."');
281
+ });
282
+
283
+ it('leaves the block-level hatch alone', () => {
284
+ const css = generateThemeCSS(
285
+ sectionWithPartCustomCss({
286
+ customCss: '& { padding: 4px; }',
287
+ 'customCss/dropdown': '& { padding: 8px; }'
288
+ })
289
+ );
290
+
291
+ expect(css).toContain(`${SCOPE} {`);
292
+ expect(css).toContain('padding: 4px');
293
+ expect(css).toContain(`${PART_SCOPE} {`);
294
+ expect(css).toContain('padding: 8px');
295
+ });
296
+ });
@@ -53,12 +53,17 @@ const text = (id: string) => ({
53
53
  hidden: false
54
54
  });
55
55
 
56
- const accordion = (id: string, styles: Record<string, unknown>, inner?: unknown) => ({
56
+ const accordion = (
57
+ id: string,
58
+ styles: Record<string, unknown>,
59
+ inner?: unknown,
60
+ properties: Record<string, unknown> = {}
61
+ ) => ({
57
62
  id,
58
63
  type: 'accordion',
59
64
  label: 'Accordion',
60
65
  value: '',
61
- properties: { defaultOpen: true },
66
+ properties: { defaultOpen: true, ...properties },
62
67
  styles,
63
68
  order: 0,
64
69
  hidden: false,
@@ -69,10 +74,15 @@ const accordion = (id: string, styles: Record<string, unknown>, inner?: unknown)
69
74
  });
70
75
 
71
76
  /** The outer accordion carries the styles; the inner one carries the same parts. */
72
- const composition = (styles: Record<string, unknown>) =>
73
- accordion('outer', styles, accordion('inner', {}));
74
-
75
- const sectionWith = (styles: Record<string, unknown>): Section[] =>
77
+ const composition = (
78
+ styles: Record<string, unknown>,
79
+ properties: Record<string, unknown> = {}
80
+ ) => accordion('outer', styles, accordion('inner', {}), properties);
81
+
82
+ const sectionWith = (
83
+ styles: Record<string, unknown>,
84
+ properties: Record<string, unknown> = {}
85
+ ): Section[] =>
76
86
  [
77
87
  {
78
88
  id: 'sec-1',
@@ -83,7 +93,7 @@ const sectionWith = (styles: Record<string, unknown>): Section[] =>
83
93
  hidden: false,
84
94
  properties: {},
85
95
  styles: {},
86
- blocks: [composition(styles)]
96
+ blocks: [composition(styles, properties)]
87
97
  }
88
98
  ] as unknown as Section[];
89
99
 
@@ -227,3 +237,19 @@ describe('the selector shape published compositions already rely on', () => {
227
237
  });
228
238
  });
229
239
  });
240
+
241
+ describe('the part-scoped hatch is confined exactly as a part control is', () => {
242
+ it('does not reach the nested block that names its header the same way', () => {
243
+ // The hatch inherits buildPartSelectors, so it inherits the ownership
244
+ // guard. Written as a real composition because that is where the leak
245
+ // the guard exists for actually appears.
246
+ const css = generateThemeCSS(
247
+ sectionWith({}, { 'customCss/header': '& { color: red; }' })
248
+ );
249
+ const { container } = renderComposition({});
250
+ const selectors = selectorsFor(css, 'header');
251
+
252
+ expect(selectors.length).toBeGreaterThan(0);
253
+ expect(matchIds(container, selectors)).toEqual(['outer']);
254
+ });
255
+ });
@@ -266,6 +266,39 @@ describe('generateThemeCSS — checkout flow can never be hidden', () => {
266
266
  expect(ancestorCss).not.toContain('visibility: hidden');
267
267
  });
268
268
 
269
+ it('closes the PART-scoped hatch on the outlet and on an ancestor', () => {
270
+ // The part hatch is a second free-form channel into the same stylesheet,
271
+ // so it has to be filtered on its generated text exactly as the block
272
+ // hatch is — otherwise the outlet gained a way to hide itself the day the
273
+ // part panel got its own box.
274
+ const outletCss = generateThemeCSS(
275
+ section([
276
+ flowOutlet({
277
+ properties: {
278
+ 'customCss/place-order-button':
279
+ '& { display: none } & { border: 1px solid red }'
280
+ }
281
+ })
282
+ ])
283
+ );
284
+
285
+ expect(outletCss).not.toContain('display: none');
286
+ expect(outletCss).toContain('border: 1px solid red');
287
+
288
+ const ancestorCss = generateThemeCSS(
289
+ section([
290
+ {
291
+ id: 'flow-column',
292
+ type: 'group',
293
+ properties: { 'customCss/panel': '& { visibility: hidden }' },
294
+ blocks: [flowOutlet()]
295
+ }
296
+ ])
297
+ );
298
+
299
+ expect(ancestorCss).not.toContain('visibility: hidden');
300
+ });
301
+
269
302
  it('leaves an unrelated block free to hide itself', () => {
270
303
  const css = generateThemeCSS(
271
304
  section([
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { MOBILE_MEDIA_QUERY, TABLET_MEDIA_QUERY } from './breakpoints';
19
+ import { buildPartScopeSelector } from './part-styles';
19
20
 
20
21
  /** Nothing here is a legitimate need in a block's scoped CSS, and each is a way out of it. */
21
22
  const FORBIDDEN = [
@@ -231,3 +232,44 @@ export const buildCustomCss = (
231
232
 
232
233
  return css;
233
234
  };
235
+
236
+ /** A property key of the form `customCss/<part>` carries a part-scoped hatch. */
237
+ export const PART_CUSTOM_CSS_PREFIX = 'customCss/';
238
+
239
+ /**
240
+ * The hatch, one level in.
241
+ *
242
+ * The block hatch reaches a part already (`& [data-part="x"] { … }`), but only
243
+ * from the block's own box — so a designer working inside a part had to climb
244
+ * back out to the block panel, and both boxes wrote the one `customCss` value,
245
+ * where either could silently overwrite the other. A `customCss/<part>`
246
+ * property is scoped to that part's selector instead, ownership guard
247
+ * included, so it is confined exactly as a part control is.
248
+ *
249
+ * No `!important` is added, exactly as at block level: a hand-typed one passes
250
+ * through, which is the documented way to beat a renderer's inline default.
251
+ */
252
+ export const buildPartCustomCss = (
253
+ properties: Record<string, unknown> | undefined | null,
254
+ blockSelector: string,
255
+ targetBreakpoint?: string
256
+ ): string => {
257
+ let css = '';
258
+
259
+ Object.entries(properties || {}).forEach(([key, value]) => {
260
+ if (!key.startsWith(PART_CUSTOM_CSS_PREFIX)) return;
261
+
262
+ const scope = buildPartScopeSelector(
263
+ blockSelector,
264
+ key.slice(PART_CUSTOM_CSS_PREFIX.length)
265
+ );
266
+ if (!scope) return;
267
+
268
+ // The synthetic bag reuses the whole block-level path: the forbidden
269
+ // patterns, the brace balance, the responsive fall-through and the
270
+ // media-query emission all behave as they do for the block hatch.
271
+ css += buildCustomCss({ customCss: value }, scope, targetBreakpoint);
272
+ });
273
+
274
+ return css;
275
+ };
@@ -6,7 +6,7 @@ import {
6
6
  escapeCssAttributeValue
7
7
  } from './css-safety';
8
8
  import { buildPartSelectors, splitPartStyles } from './part-styles';
9
- import { buildCustomCss } from './custom-css';
9
+ import { buildCustomCss, buildPartCustomCss } from './custom-css';
10
10
  import {
11
11
  CHECKOUT_FLOW_OUTLET_TYPE,
12
12
  containsCheckoutFlowOutlet,
@@ -1211,6 +1211,19 @@ const collectBlockCSS = (
1211
1211
  );
1212
1212
 
1213
1213
  css += guardsCheckoutFlow ? stripHidingCustomCss(customCss) : customCss;
1214
+
1215
+ // After the part RULES on purpose: same selector, same specificity, so
1216
+ // source order settles a tie and a hand-written `!important` in the
1217
+ // hatch is the designer's way to beat the generated part rule.
1218
+ const partCustomCss = buildPartCustomCss(
1219
+ block.properties as Record<string, unknown>,
1220
+ selector,
1221
+ targetBreakpoint
1222
+ );
1223
+
1224
+ css += guardsCheckoutFlow
1225
+ ? stripHidingCustomCss(partCustomCss)
1226
+ : partCustomCss;
1214
1227
  }
1215
1228
 
1216
1229
  if (block.blocks && block.blocks.length > 0) {
@@ -1260,6 +1273,9 @@ export const generateThemeCSS = (
1260
1273
  }
1261
1274
 
1262
1275
  if (section.properties) {
1276
+ // No part hatch here on purpose: no editor surface writes
1277
+ // `customCss/<part>` on a section — the part panel opens on a selected
1278
+ // BLOCK — so the asymmetry is intentional, not an omission.
1263
1279
  const customCss = buildCustomCss(
1264
1280
  section.properties as Record<string, unknown>,
1265
1281
  sectionSelector,
@@ -231,3 +231,17 @@ export const buildPartSelectors = (
231
231
 
232
232
  return suffixes.map((suffix) => `${base}${suffix}${owned}`);
233
233
  };
234
+
235
+ /**
236
+ * The scope a part-scoped custom CSS block is written against.
237
+ *
238
+ * It is the part's own base selector, ownership guard included, so the hatch
239
+ * can no more leak into a nested block than a part control can — the sanitiser
240
+ * and the guard are reused verbatim rather than re-derived.
241
+ */
242
+ export const buildPartScopeSelector = (
243
+ blockSelector: string,
244
+ part: string
245
+ ): string | null =>
246
+ buildPartSelectors(blockSelector, { part, state: null, styles: {} })[0] ??
247
+ null;