@akinon/pz-theme 2.0.115-rc.0 → 2.0.115

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.
@@ -39,6 +39,13 @@ import type { Section } from '../../theme-section';
39
39
  * by emission order. Nothing here asserts that pair; there is no right answer
40
40
  * to assert yet.
41
41
  *
42
+ * THE STATE HAS TWO EXPRESSIONS, emitted as one rule. The `body` stamp is
43
+ * written from a layout effect and so is missing from the server HTML; the
44
+ * brand's header also renders the attribute on its own root (`useChromeMode`),
45
+ * and `:is(body:has(…))` reads it there, which is what makes the first paint
46
+ * dock. Everything the ladder above promises has to hold for EACH expression,
47
+ * so each is asserted below.
48
+ *
42
49
  * WHY THERE IS NO `getComputedStyle` TEST BELOW, and why adding one would make
43
50
  * this file worse: jsdom has no cascade worth the name. Its `getComputedStyle`
44
51
  * resolves competing rules by SOURCE ORDER ALONE and ignores specificity
@@ -46,9 +53,17 @@ import type { Section } from '../../theme-section';
46
53
  * cascade test would therefore assert the opposite of what a browser does, pass
47
54
  * today for the wrong reason, and keep passing after someone deleted the very
48
55
  * qualifier it was written to protect. So the weight is asserted directly.
56
+ *
57
+ * NOR IS THE `:has` EXPRESSION EVER HANDED TO THE DOM. jsdom's selector engine
58
+ * answers `:has()` inside `:is()` with no match at all (measured: zero results
59
+ * on a document the expression plainly matches), so a DOM assertion on it
60
+ * would fail for a reason that has nothing to do with docked. It is asserted as
61
+ * text, and its weight is worked out by hand.
49
62
  */
50
63
 
51
64
  const DOCKED = 'body[data-chrome-mode~="docked"]';
65
+ /** The first-paint expression: the attribute on the brand's own chrome root. */
66
+ const DOCKED_HAS = ':is(body:has([data-chrome-mode~="docked"]))';
52
67
  const SCROLLED = '[data-scroll-state~="scrolled"]';
53
68
  const SECTION = '[data-section-id="sec-1"]';
54
69
 
@@ -79,18 +94,33 @@ const block = (id: string, styles: Record<string, unknown>, type = 'group') => (
79
94
  });
80
95
 
81
96
  /**
82
- * Weighs the two selectors this file is about, and nothing else.
97
+ * Weighs the selectors this file is about, and nothing else.
83
98
  *
84
99
  * Deliberately naive: it counts attribute selectors and leading type selectors,
85
- * which is all either selector contains. A general specificity parser would be
86
- * a second implementation to get wrong, and the honest alternative — asking the
87
- * DOM — does not exist here (see the note above).
100
+ * which is all the `body` and `scrolled` expressions contain. A general
101
+ * specificity parser would be a second implementation to get wrong, and the
102
+ * honest alternative — asking the DOM — does not exist here (see the note
103
+ * above). The `:has` expression is brought within its reach by `unwrap` below.
88
104
  */
89
105
  const weigh = (selector: string) => ({
90
106
  attributes: (selector.match(/\[/g) || []).length,
91
107
  types: (selector.match(/(^|[\s>+~])[a-z][a-z0-9-]*/g) || []).length
92
108
  });
93
109
 
110
+ /**
111
+ * Removes `:is(` / `:has(` and their closing parens, leaving the arguments in
112
+ * place.
113
+ *
114
+ * Exact for weight, by the spec rather than by approximation: `:is()` and
115
+ * `:has()` take the specificity of their most specific argument and add
116
+ * nothing of their own, and each here has exactly one argument. So
117
+ * `:is(body:has([a]))` weighs what `body[a]` weighs — (0,1,1) — and unwrapping
118
+ * is the hand calculation written down. Only safe on selectors with no other
119
+ * parentheses, which is true of every one it is given.
120
+ */
121
+ const unwrap = (selector: string) =>
122
+ selector.replace(/:(is|has)\(/g, '').replace(/\)/g, '');
123
+
94
124
  afterEach(() => {
95
125
  document.body.removeAttribute('data-chrome-mode');
96
126
  document.body.innerHTML = '';
@@ -105,14 +135,42 @@ describe('the `body` qualifier is load-bearing — do not tidy it away', () => {
105
135
  expect(selector).toBe(`${DOCKED} ${SECTION}`);
106
136
  });
107
137
 
108
- it('outweighs the scrolled selector, which it ties with on attributes alone', () => {
109
- const [docked] = buildRootStateSelectors(SECTION, 'docked');
110
- const [scrolled] = buildRootStateSelectors(SECTION, 'scrolled');
138
+ it('keeps the first-paint expression second, wrapped in :is(), after the body stamp', () => {
139
+ // Literal text for the same reason as above, and for two more. The FIRST
140
+ // expression is grepped for by pz-dynamic-widget, so it must stay first and
141
+ // byte-identical. The `:is()` wrapper looks removable and is not: the two
142
+ // expressions share one comma-separated rule, and without the forgiving
143
+ // `:is()` a browser that cannot parse `:has` would drop the body branch
144
+ // along with it.
145
+ expect(buildRootStateSelectors(SECTION, 'docked')).toEqual([
146
+ `${DOCKED} ${SECTION}`,
147
+ `${DOCKED_HAS} ${SECTION}`
148
+ ]);
149
+ });
111
150
 
112
- // Same attribute count: this is the tie, and it is why order would decide.
113
- expect(weigh(docked).attributes).toBe(weigh(scrolled).attributes);
114
- // The type selector is the entire margin of victory.
115
- expect(weigh(docked).types).toBeGreaterThan(weigh(scrolled).types);
151
+ it('weighs the :has expression exactly as the body stamp, so the ladder holds either way', () => {
152
+ const [body, has] = buildRootStateSelectors(SECTION, 'docked');
153
+
154
+ // By hand: `:is(X)` and `:has(X)` each weigh X, so the prefix is `body`
155
+ // (0,0,1) plus one attribute (0,1,0) — (0,1,1), the body stamp's weight.
156
+ // Unwrapping lands on the body stamp's selector itself, which is the
157
+ // strongest form that equality can take.
158
+ expect(unwrap(has)).toBe(body);
159
+ expect(weigh(unwrap(has))).toEqual(weigh(body));
160
+ expect(weigh(unwrap(has))).toEqual({ attributes: 2, types: 1 });
161
+ });
162
+
163
+ it('outweighs the scrolled selector with every expression, tying on attributes alone', () => {
164
+ const [scrolled] = buildRootStateSelectors(SECTION, 'scrolled');
165
+ const dockedExpressions = buildRootStateSelectors(SECTION, 'docked');
166
+
167
+ expect(dockedExpressions).toHaveLength(2);
168
+ dockedExpressions.map(unwrap).forEach((docked) => {
169
+ // Same attribute count: this is the tie, and it is why order would decide.
170
+ expect(weigh(docked).attributes).toBe(weigh(scrolled).attributes);
171
+ // The type selector is the entire margin of victory.
172
+ expect(weigh(docked).types).toBeGreaterThan(weigh(scrolled).types);
173
+ });
116
174
  });
117
175
 
118
176
  it('matches the same band as scrolled on an inner page, so the tie is real', () => {
@@ -164,8 +222,11 @@ describe('the generated CSS', () => {
164
222
  })
165
223
  ]);
166
224
 
225
+ // Both expressions in ONE rule, the body stamp first — which is also why
226
+ // the second must be the forgiving `:is()` form (see above).
167
227
  expect(css).toContain(
168
- `${DOCKED} ${SECTION} {\n background-color: rgba(255, 255, 255, 1);\n}`
228
+ `${DOCKED} ${SECTION},\n${DOCKED_HAS} ${SECTION} {\n` +
229
+ ' background-color: rgba(255, 255, 255, 1);\n}'
169
230
  );
170
231
  expect(css).toContain(
171
232
  `${SCROLLED} ${SECTION} {\n background-color: rgba(245, 245, 245, 1);\n}`
@@ -182,7 +243,8 @@ describe('the generated CSS', () => {
182
243
  ]);
183
244
 
184
245
  expect(css).toContain(
185
- `${DOCKED} [data-block-id="bar"] {\n color: #4a0e26;\n}`
246
+ `${DOCKED} [data-block-id="bar"],\n` +
247
+ `${DOCKED_HAS} [data-block-id="bar"] {\n color: #4a0e26;\n}`
186
248
  );
187
249
  });
188
250
 
@@ -197,24 +259,28 @@ describe('the generated CSS', () => {
197
259
 
198
260
  describe('docked as a part state', () => {
199
261
  it('conditions a part on the page without losing the ownership guard', () => {
200
- const [selector] = buildPartSelectors('[data-section-id="sec-1"]', {
262
+ const selectors = buildPartSelectors('[data-section-id="sec-1"]', {
201
263
  part: 'bar',
202
264
  state: 'docked',
203
265
  styles: {}
204
266
  });
267
+ const guarded =
268
+ '[data-section-id="sec-1"] [data-part="bar"]' +
269
+ ':not(:where([data-section-id="sec-1"] [data-block-id] *))';
205
270
 
206
271
  // The whole compound, guard included, is substituted into the ancestor
207
272
  // pattern — so a docked rule can no more reach into a nested block than the
208
- // stateless one can.
273
+ // stateless one can. Into BOTH patterns: the first-paint expression gets
274
+ // the guard too.
209
275
  //
210
276
  // Asserted as text rather than by querying the DOM on purpose: the guard
211
277
  // uses `:where()`, and the nwsapi build in this workspace's node_modules
212
278
  // cannot parse it (it is the cause of the three known-red suites here). A
213
279
  // DOM assertion would fail for a reason that has nothing to do with docked.
214
- expect(selector).toBe(
215
- `${DOCKED} [data-section-id="sec-1"] [data-part="bar"]` +
216
- ':not(:where([data-section-id="sec-1"] [data-block-id] *))'
217
- );
280
+ expect(selectors).toEqual([
281
+ `${DOCKED} ${guarded}`,
282
+ `${DOCKED_HAS} ${guarded}`
283
+ ]);
218
284
  });
219
285
 
220
286
  it('parses `bar/docked/color` as a part key and buckets it', () => {
@@ -237,6 +303,9 @@ describe('docked as a part state', () => {
237
303
  expect(css).toContain(
238
304
  `${DOCKED} [data-section-id="sec-1"] [data-part="bar"]`
239
305
  );
306
+ expect(css).toContain(
307
+ `${DOCKED_HAS} [data-section-id="sec-1"] [data-part="bar"]`
308
+ );
240
309
  });
241
310
  });
242
311
 
@@ -143,9 +143,9 @@ const STATE_SELECTORS: Record<string, string[]> = {
143
143
  'overlay-open': ['body[data-overlay-state~="open"] &'],
144
144
  /**
145
145
  * The third page-level state: the chrome is DOCKED above the page rather than
146
- * sitting over it. `body` carries `data-chrome-mode` (see
147
- * chrome/chrome-mode.ts), stamped by the brand, because only the brand knows
148
- * which of its routes carry a hero.
146
+ * sitting over it. `data-chrome-mode` (see chrome/chrome-mode.ts) is rendered
147
+ * by the brand on its own chrome element and stamped on `body`, because only
148
+ * the brand knows which of its routes carry a hero.
149
149
  *
150
150
  * THE `body` QUALIFIER IS A DELIBERATE SPECIFICITY BUMP, not tidiness. On an
151
151
  * inner page this and `scrolled` BOTH match at once, and unqualified they
@@ -171,8 +171,40 @@ const STATE_SELECTORS: Record<string, string[]> = {
171
171
  * the answer. Whoever asks it should take that rung rather than dropping one
172
172
  * of these two qualifiers, which only hands `scrolled` back a fight it should
173
173
  * lose.
174
+ *
175
+ * THE SECOND EXPRESSION IS THE FIRST-PAINT SIGNAL. The `body` stamp is
176
+ * written from a layout effect, so it is not in the server HTML, and every
177
+ * inner page used to paint the transparent overlay header until hydration
178
+ * ran the effect. `useChromeMode` / `chromeModeAttrs` render the same
179
+ * attribute on the brand's own chrome element, which IS in the server HTML,
180
+ * and `body:has(…)` reads it from there. The first expression stays, byte for
181
+ * byte — pz-dynamic-widget greps for it, and it is what the designer pin, a
182
+ * browser without `:has` and hand-written brand CSS all match.
183
+ *
184
+ * SAME WEIGHT, so the ladder above holds whichever branch matched. `:is()`
185
+ * and `:has()` each weigh their most specific argument and add nothing of
186
+ * their own, so the prefix is `body` plus one attribute, (0,1,1), exactly
187
+ * the first expression's: `scrolled` < `docked` = `overlay-open` as before.
188
+ *
189
+ * WRAPPED IN `:is()` because a state's expressions are not separate rules:
190
+ * `generatePartCSS` and `generateRootStateCSS` (utils/index.ts) join them
191
+ * into ONE comma-separated selector list, and a browser drops a whole list
192
+ * when it cannot parse one member — one without `:has` would lose the `body`
193
+ * branch with it and never dock at all. `:is()` takes a forgiving selector
194
+ * list, so that browser discards only the `:has` branch (the `:is()` then
195
+ * matches nothing) and the `body` stamp still docks the header after
196
+ * hydration.
197
+ *
198
+ * A DESCENDANT MATCH, not `:has(> …)`: the chrome element sits inside the
199
+ * layout and the chrome slot, never directly under `body`. Emission is not a
200
+ * reason either way — pz-theme writes generated CSS through
201
+ * `dangerouslySetInnerHTML`, verbatim, which the quotes in both expressions
202
+ * already depend on — so the choice is about what matches.
174
203
  */
175
- docked: ['body[data-chrome-mode~="docked"] &']
204
+ docked: [
205
+ 'body[data-chrome-mode~="docked"] &',
206
+ ':is(body:has([data-chrome-mode~="docked"])) &'
207
+ ]
176
208
  };
177
209
 
178
210
  export const PART_STYLE_STATES = Object.keys(STATE_SELECTORS);