@akinon/pz-theme 2.0.46-rc.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 +7 -31
- package/package.json +4 -4
- package/src/__tests__/custom-css.test.ts +115 -0
- package/src/__tests__/part-style-scope.test.tsx +33 -7
- package/src/utils/__tests__/checkout-safety.test.ts +33 -0
- package/src/utils/custom-css.ts +42 -0
- package/src/utils/index.ts +17 -1
- package/src/utils/part-styles.ts +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.46
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies
|
|
11
|
-
- Updated dependencies [2fea4143]
|
|
12
|
-
- Updated dependencies [b55acb768]
|
|
13
|
-
- Updated dependencies [760258c1]
|
|
14
|
-
- Updated dependencies [143be2b9d]
|
|
15
|
-
- Updated dependencies [7889b08f]
|
|
16
|
-
- Updated dependencies [9f8cd3bc5]
|
|
17
|
-
- Updated dependencies [bfafa3f4]
|
|
18
|
-
- Updated dependencies [57d7eb30]
|
|
19
|
-
- Updated dependencies [d99a6a7d5]
|
|
20
|
-
- Updated dependencies [9db81a71]
|
|
21
|
-
- Updated dependencies [591e345e1]
|
|
22
|
-
- Updated dependencies [4de5303c5]
|
|
23
|
-
- Updated dependencies [95b139dc1]
|
|
24
|
-
- Updated dependencies [1d00f2d0]
|
|
25
|
-
- Updated dependencies [4ac7b2a1]
|
|
26
|
-
- Updated dependencies [e9598c71]
|
|
27
|
-
- Updated dependencies [4998a963]
|
|
28
|
-
- Updated dependencies [804d2bd6]
|
|
29
|
-
- Updated dependencies [3909d3224]
|
|
30
|
-
- Updated dependencies [6a3d8a63]
|
|
31
|
-
- Updated dependencies [e18836b2]
|
|
32
|
-
- @akinon/next@2.0.46-rc.0
|
|
33
|
-
- @akinon/theme-contract@2.0.46-rc.0
|
|
3
|
+
## 2.0.46
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
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
|
|
34
10
|
|
|
35
11
|
## 2.0.45
|
|
36
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
|
|
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
|
|
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
|
|
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
|
|
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 = (
|
|
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 = (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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([
|
package/src/utils/custom-css.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/utils/index.ts
CHANGED
|
@@ -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,
|
package/src/utils/part-styles.ts
CHANGED
|
@@ -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;
|