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

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.92-beta.0
3
+ ## 2.0.92
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.92-beta.0
9
- - @akinon/theme-contract@2.0.92-beta.0
7
+ - abd46ed1: ZERO-4997: Let an open overlay outrank the scrolled state
8
+ - @akinon/next@2.0.92
9
+ - @akinon/theme-contract@2.0.92
10
10
 
11
11
  ## 2.0.91
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.92-beta.0",
4
+ "version": "2.0.92",
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.92-beta.0",
11
+ "@akinon/next": "2.0.92",
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.92-beta.0",
16
+ "@akinon/theme-contract": "2.0.92",
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.92-beta.0",
23
+ "@akinon/next": "2.0.92",
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",
@@ -24,12 +24,20 @@ import type { Section } from '../../theme-section';
24
24
  * ON AN INNER PAGE BOTH STATES MATCH AT ONCE. The shopper scrolls a docked
25
25
  * header, so `[data-scroll-state~="scrolled"]` and `body[data-chrome-mode~=
26
26
  * "docked"]` are both true of the same band, and both rules set the same
27
- * property. Every other ancestor-matched state weighs (0,2,0) against an
28
- * element selector; the `body` makes this one (0,2,1), so the mode wins on
29
- * specificity rather than on whichever rule the generator happened to write
30
- * last. That is the right answer and not merely a chosen one: `scrolled`
31
- * describes how far the page has slid under a bar that floats over it, and once
32
- * the bar is docked the question has no meaning.
27
+ * property. Unqualified they would both weigh (0,2,0) against an element
28
+ * selector; the `body` makes this one (0,2,1), so the mode wins on specificity
29
+ * rather than on whichever rule the generator happened to write last. That is
30
+ * the right answer and not merely a chosen one: `scrolled` describes how far
31
+ * the page has slid under a bar that floats over it, and once the bar is docked
32
+ * the question has no meaning.
33
+ *
34
+ * `overlay-open` CARRIES THE SAME QUALIFIER, for the same reason and against
35
+ * the same opponent (ZERO-4997; see scrolled-state-styles.test.ts). So the
36
+ * ladder reads: `scrolled` at (0,2,0) on the bottom, unable to leave it because
37
+ * its attribute is not on `body`, and these two above it at (0,2,1) — TIED with
38
+ * each other, which means a docked header with an open panel is still settled
39
+ * by emission order. Nothing here asserts that pair; there is no right answer
40
+ * to assert yet.
33
41
  *
34
42
  * WHY THERE IS NO `getComputedStyle` TEST BELOW, and why adding one would make
35
43
  * this file worse: jsdom has no cascade worth the name. Its `getComputedStyle`
@@ -89,7 +97,7 @@ afterEach(() => {
89
97
  });
90
98
 
91
99
  describe('the `body` qualifier is load-bearing — do not tidy it away', () => {
92
- it('keeps `body` in the docked selector, which is the only thing that outranks scrolled', () => {
100
+ it('keeps `body` in the docked selector, which is the only thing outranking scrolled here', () => {
93
101
  const [selector] = buildRootStateSelectors(SECTION, 'docked');
94
102
 
95
103
  // Asserted as literal text, because the qualifier reads like a redundant
@@ -3,6 +3,7 @@ import { stripHidingStyles } from '../checkout-safety';
3
3
  import {
4
4
  buildPartSelectors,
5
5
  buildRootStateKey,
6
+ buildRootStateSelectors,
6
7
  parseRootStateKey,
7
8
  splitPartStyles
8
9
  } from '../part-styles';
@@ -210,7 +211,8 @@ describe('checkout safety', () => {
210
211
  });
211
212
 
212
213
  describe('the overlay-open state', () => {
213
- const OPEN = '[data-overlay-state~="open"]';
214
+ // Carries `body` deliberately; the block at the end of this file is why.
215
+ const OPEN = 'body[data-overlay-state~="open"]';
214
216
 
215
217
  it('conditions a part on the page, exactly as scrolled does', () => {
216
218
  const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
@@ -259,3 +261,213 @@ describe('the overlay-open state', () => {
259
261
  expect(document.querySelectorAll(selector)).toHaveLength(0);
260
262
  });
261
263
  });
264
+
265
+ /**
266
+ * `overlay-open` outranking `scrolled`, which is the whole point of the `body`
267
+ * in its selector.
268
+ *
269
+ * THE BUG IT CLOSES, reported from a live storefront: scroll the home page far
270
+ * enough that the header takes its scrolled paint, THEN open the mega menu, and
271
+ * the band stays scroll-dark. Open the menu without scrolling first and it
272
+ * turns white. Both states are true of the same band at the same moment and
273
+ * both set the same property, so one has to lose — and unqualified both weigh
274
+ * (0,2,0), which left the winner to whichever rule the generator wrote last.
275
+ * That order is not a decision made in this package: root states are bucketed
276
+ * as `splitPartStyles` iterates the style object, so it is the order the state
277
+ * keys happen to sit in the merchant's stored composition JSON.
278
+ *
279
+ * WHAT IS ASSERTED, THEREFORE, IS THE RELATIONSHIP AND NOT THE STRING: that the
280
+ * overlay colour is the one a browser would paint, with the keys stored in BOTH
281
+ * orders. A test that only checked the selector text would go green again the
282
+ * moment someone re-derived the same tie a different way, and a test that only
283
+ * used the failing order could not tell a fix from a lucky emission.
284
+ *
285
+ * WHY NOT `getComputedStyle`: jsdom has no cascade worth the name — it resolves
286
+ * competing rules by source order alone and ignores specificity entirely (this
287
+ * was measured; see docked-state-styles.test.ts). A cascade test here would
288
+ * assert the opposite of a browser, pass today for the wrong reason, and keep
289
+ * passing after someone deleted the qualifier it exists to protect.
290
+ */
291
+ describe('an open overlay outranks the scrolled state', () => {
292
+ const SECTION = '[data-section-id="sec-1"]';
293
+ const OPEN_WHITE = 'rgba(255, 255, 255, 1)';
294
+ const SCROLLED_GREY = 'rgba(245, 245, 245, 1)';
295
+
296
+ // Read back out of the table rather than retyped. Only the first case below
297
+ // asserts the selector as text; everything after it is about the relationship
298
+ // between the two, and retyping either one would quietly turn those cases
299
+ // into assertions about this file instead of about the table.
300
+ const overlaySelector = () =>
301
+ buildRootStateSelectors(SECTION, 'overlay-open')[0];
302
+ const scrolledSelector = () =>
303
+ buildRootStateSelectors(SECTION, 'scrolled')[0];
304
+
305
+ /**
306
+ * Weighs the two selectors this block is about, and nothing else.
307
+ *
308
+ * Naive on purpose — it counts attribute selectors and leading type
309
+ * selectors, which is all either selector contains. A general specificity
310
+ * parser would be a second implementation to get wrong, and the honest
311
+ * alternative, asking the DOM, does not exist here.
312
+ */
313
+ const weigh = (selector: string) => ({
314
+ attributes: (selector.match(/\[/g) || []).length,
315
+ types: (selector.match(/(^|[\s>+~])[a-z][a-z0-9-]*/g) || []).length
316
+ });
317
+
318
+ const declaredBackground = (css: string, selector: string) => {
319
+ const at = css.indexOf(`${selector} {`);
320
+ if (at === -1) return null;
321
+
322
+ const rule = css.slice(at, css.indexOf('}', at));
323
+ const declaration = rule.match(/background-color: ([^;]+);/);
324
+
325
+ return declaration ? declaration[1] : null;
326
+ };
327
+
328
+ /**
329
+ * Which of the two competing declarations a browser would actually paint.
330
+ *
331
+ * A mini-cascade, and honest about being one: the heavier selector wins, and
332
+ * a TIE falls to whichever rule was written last. Modelling the tie-break is
333
+ * not decoration — without it, a build where the `body` had been deleted
334
+ * would still come out white in the overlay-last order below, and the pair of
335
+ * cases would stop proving order-independence.
336
+ */
337
+ const painted = (css: string) => {
338
+ const overlay = overlaySelector();
339
+ const scrolled = scrolledSelector();
340
+
341
+ if (weigh(overlay).types !== weigh(scrolled).types) {
342
+ return declaredBackground(
343
+ css,
344
+ weigh(overlay).types > weigh(scrolled).types ? overlay : scrolled
345
+ );
346
+ }
347
+
348
+ return declaredBackground(
349
+ css,
350
+ css.indexOf(`${overlay} {`) > css.indexOf(`${scrolled} {`)
351
+ ? overlay
352
+ : scrolled
353
+ );
354
+ };
355
+
356
+ const band = (styles: Record<string, unknown>) =>
357
+ generateThemeCSS([section('sec-1', styles)]);
358
+
359
+ it('keeps `body` in the overlay selector, which is the entire tie-break', () => {
360
+ // The one literal assertion here, because the qualifier reads like a
361
+ // redundant ancestor to anyone who has not read why it is there.
362
+ expect(overlaySelector()).toBe(
363
+ `body[data-overlay-state~="open"] ${SECTION}`
364
+ );
365
+ });
366
+
367
+ it('outweighs the scrolled selector, which it ties with on attributes alone', () => {
368
+ // Same attribute count: this is the tie, and it is why order would decide.
369
+ expect(weigh(overlaySelector()).attributes).toBe(
370
+ weigh(scrolledSelector()).attributes
371
+ );
372
+ // The type selector is the entire margin of victory.
373
+ expect(weigh(overlaySelector()).types).toBeGreaterThan(
374
+ weigh(scrolledSelector()).types
375
+ );
376
+ });
377
+
378
+ it('paints the overlay colour with the SCROLLED key stored last', () => {
379
+ // The order that failed in production: scrolled is emitted after overlay,
380
+ // so nothing about the emission is saving this — only the weight is.
381
+ const css = band({
382
+ 'background-color': 'transparent',
383
+ ':overlay-open/background-color': '#ffffff',
384
+ ':scrolled/background-color': '#f5f5f5'
385
+ });
386
+
387
+ expect(css.indexOf(`${scrolledSelector()} {`)).toBeGreaterThan(
388
+ css.indexOf(`${overlaySelector()} {`)
389
+ );
390
+ expect(painted(css)).toBe(OPEN_WHITE);
391
+ });
392
+
393
+ it('paints the overlay colour with the OVERLAY key stored last too', () => {
394
+ // The mirror case, which a merchant reaches simply by editing the two
395
+ // controls in the other order. Same answer — which is what makes the pair
396
+ // proof rather than luck. On a build with the `body` deleted THIS case
397
+ // still comes out white, because emission order happens to favour the
398
+ // overlay here; only the case above turns red. Keep both, or the next
399
+ // reader has no way to tell a fix from a fortunate order.
400
+ const css = band({
401
+ 'background-color': 'transparent',
402
+ ':scrolled/background-color': '#f5f5f5',
403
+ ':overlay-open/background-color': '#ffffff'
404
+ });
405
+
406
+ expect(css.indexOf(`${overlaySelector()} {`)).toBeGreaterThan(
407
+ css.indexOf(`${scrolledSelector()} {`)
408
+ );
409
+ expect(painted(css)).toBe(OPEN_WHITE);
410
+ });
411
+
412
+ it('still emits the scrolled rule, which the shopper sees before opening', () => {
413
+ // The overlay only outranks it WHILE a panel is open. Losing the scrolled
414
+ // rule entirely would be the opposite bug, and just as visible.
415
+ const css = band({
416
+ ':scrolled/background-color': '#f5f5f5',
417
+ ':overlay-open/background-color': '#ffffff'
418
+ });
419
+
420
+ expect(declaredBackground(css, scrolledSelector())).toBe(SCROLLED_GREY);
421
+ });
422
+
423
+ it('matches the same band as scrolled at once, so the tie is real', () => {
424
+ // Not hypothetical: this is the reported sequence — the page is scrolled
425
+ // and the panel is open, so both rules target one element and one property.
426
+ document.body.setAttribute('data-overlay-state', 'open');
427
+ document.body.innerHTML =
428
+ '<div data-chrome-slot="header" data-scroll-state="scrolled">' +
429
+ '<div data-section-id="sec-1"></div>' +
430
+ '</div>';
431
+
432
+ expect(document.querySelectorAll(overlaySelector())).toHaveLength(1);
433
+ expect(document.querySelectorAll(scrolledSelector())).toHaveLength(1);
434
+
435
+ document.body.removeAttribute('data-overlay-state');
436
+ });
437
+
438
+ it('matches nothing when no panel is open, so scrolled keeps the band', () => {
439
+ // Every composition that styled only `scrolled` has to render as it did.
440
+ document.body.removeAttribute('data-overlay-state');
441
+ document.body.innerHTML = '<div data-section-id="sec-1"></div>';
442
+
443
+ expect(document.querySelectorAll(overlaySelector())).toHaveLength(0);
444
+ });
445
+
446
+ /**
447
+ * THE OTHER HALF OF THE BUMP, pinned because it is a bargain rather than a
448
+ * free win. A page state already composes an ancestor selector on top of
449
+ * whatever an element-matched state weighs, so `overlay-open` and `hover` on
450
+ * one part used to TIE — order-dependent, i.e. a coin-flip. The qualifier
451
+ * settles it in the page state's favour, which is the same bargain `docked`
452
+ * has always made. The visible cost is a header link that stops answering the
453
+ * pointer while the panel is open, and it is asserted here so nobody
454
+ * rediscovers it as a bug.
455
+ */
456
+ it('outranks an element-matched state on the same part', () => {
457
+ const partSelector = (state: string) =>
458
+ buildPartSelectors('[data-block-id="blk-1"]', {
459
+ part: 'navLink',
460
+ state,
461
+ styles: {}
462
+ })[0];
463
+
464
+ const overlayPart = partSelector('overlay-open');
465
+ const hoverPart = partSelector('hover');
466
+
467
+ // The page state carries one ancestor selector the element state does not,
468
+ // and the `body` qualifier adds the type selector on top of it.
469
+ expect(overlayPart).toContain('body[data-overlay-state~="open"]');
470
+ expect(hoverPart).not.toContain('body[');
471
+ expect(hoverPart).toContain(':hover');
472
+ });
473
+ });
@@ -80,26 +80,81 @@ const STATE_SELECTORS: Record<string, string[]> = {
80
80
  // through an ancestor and no renderer has to stamp anything for them to
81
81
  // apply. That is what lets a header become opaque on scroll, which nothing in
82
82
  // a composition could previously say.
83
+ //
84
+ // THIS ONE IS THE BOTTOM RUNG of the page-state ladder, at (0,2,0), and it is
85
+ // left there on purpose. It COULD be lifted today — `chrome/scroll-state.tsx`
86
+ // only ever stamps the attribute on `div` elements, so `div[…] &` would be
87
+ // accurate this afternoon. It is not done because that pins the chrome slot's
88
+ // TAG NAME, which the slot is free to change and which nothing would fail
89
+ // loudly about; `body` is guaranteed by the document. Both states below
90
+ // deliberately outrank it — read them for why that is the right way round and
91
+ // not an accident.
83
92
  scrolled: ['[data-scroll-state~="scrolled"] &'],
84
- // The same trick for the other half of that header: transparent over the
85
- // hero, opaque while its menu panel is open. The panel cannot express it —
86
- // the header is not inside the panel — and the header cannot know it without
87
- // the page saying so.
88
- 'overlay-open': ['[data-overlay-state~="open"] &'],
93
+ /**
94
+ * The same trick for the other half of that header: transparent over the
95
+ * hero, opaque while its menu panel is open. The panel cannot express it —
96
+ * the header is not inside the panel — and the header cannot know it without
97
+ * the page saying so.
98
+ *
99
+ * THE `body` QUALIFIER IS A DELIBERATE SPECIFICITY BUMP, not tidiness — the
100
+ * same bump `docked` carries below, added for the same reason. A shopper who
101
+ * scrolls the home page and THEN opens the mega menu satisfies `scrolled` and
102
+ * `overlay-open` at once, on the same band, for the same property. Left
103
+ * unqualified both weigh (0,2,0), so the winner is whichever rule the
104
+ * generator emitted last — and that order is nobody's decision: root states
105
+ * are bucketed in iteration order, so it is the order the state keys happen
106
+ * to sit in the merchant's stored composition JSON. Reported as a header that
107
+ * stays scroll-dark behind an open menu, but only if you scrolled first.
108
+ * `body` makes this (0,2,1), which beats `scrolled` whatever that order is.
109
+ * The qualifier is accurate rather than hopeful: `hooks/use-overlay.ts` sets
110
+ * the attribute on `document.body`. And an open panel outranking a scroll
111
+ * position is the correct answer rather than an arbitrary one — the panel is
112
+ * the thing in front of the shopper, so it decides how the bar it hangs from
113
+ * is painted.
114
+ *
115
+ * IT ALSO OUTRANKS THE ELEMENT-MATCHED STATES on the same element — `hover`,
116
+ * `focus`, `selected` and the rest — because a page state composes an extra
117
+ * ancestor selector on top of whatever they weigh, and the qualifier adds one
118
+ * more rung to that. Unqualified it TIED with them, so this replaces a
119
+ * coin-flip with a rule rather than taking anything away that was reliable.
120
+ * The visible consequence: a header link that carries both an `overlay-open`
121
+ * colour and a `hover` colour keeps the overlay colour while the panel is
122
+ * open, and stops answering the pointer until it closes. That is the same
123
+ * bargain `docked` has always made, which is why it is spelled the same way
124
+ * — but it is a bargain, so an author who wants hover to survive an open
125
+ * panel must not author the property in both places.
126
+ */
127
+ 'overlay-open': ['body[data-overlay-state~="open"] &'],
89
128
  /**
90
129
  * The third page-level state: the chrome is DOCKED above the page rather than
91
130
  * sitting over it. `body` carries `data-chrome-mode` (see
92
131
  * chrome/chrome-mode.ts), stamped by the brand, because only the brand knows
93
132
  * which of its routes carry a hero.
94
133
  *
95
- * THE `body` QUALIFIER IS A DELIBERATE SPECIFICITY BUMP, not tidiness. Every
96
- * other ancestor-matched state weighs (0,2,0) against an element selector,
97
- * which ties with `scrolled` — and on an inner page BOTH match at once, so a
98
- * tie would be settled by whichever declaration the generator happened to
99
- * emit last. Adding the element name makes this (0,2,1), so the mode wins.
100
- * That is the correct answer rather than an arbitrary one: `scrolled`
101
- * describes how far the page has moved under a bar that floats over it, and
102
- * once the bar is docked that question no longer has a meaning.
134
+ * THE `body` QUALIFIER IS A DELIBERATE SPECIFICITY BUMP, not tidiness. On an
135
+ * inner page this and `scrolled` BOTH match at once, and unqualified they
136
+ * would tie at (0,2,0), leaving the winner to whichever declaration the
137
+ * generator happened to emit last. Adding the element name makes this
138
+ * (0,2,1), so the mode wins. That is the correct answer rather than an
139
+ * arbitrary one: `scrolled` describes how far the page has moved under a bar
140
+ * that floats over it, and once the bar is docked that question no longer has
141
+ * a meaning.
142
+ *
143
+ * WHAT THIS LADDER STILL DOES NOT SETTLE: `docked` and `overlay-open` now
144
+ * weigh the same, so THEY tie with each other, and a docked header with an
145
+ * open panel is decided by emission order again — the composition's key
146
+ * order, exactly the coin-flip the qualifiers were added to remove. That is
147
+ * unexposed rather than fixed. In the brand this was traced from, both states
148
+ * paint the same background and the same text colour, so the tie lands on the
149
+ * same pixels either way; a brand that wants its docked header to look
150
+ * different with the menu open will meet it. A rung IS reachable without a
151
+ * new attribute — repeating an attribute selector
152
+ * (`body[data-overlay-state~="open"][data-overlay-state]`) weighs (0,3,1) —
153
+ * and it is left unspent deliberately: which of the two should win is a
154
+ * product question nobody has been asked, and spending the rung guesses at
155
+ * the answer. Whoever asks it should take that rung rather than dropping one
156
+ * of these two qualifiers, which only hands `scrolled` back a fight it should
157
+ * lose.
103
158
  */
104
159
  docked: ['body[data-chrome-mode~="docked"] &']
105
160
  };