A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1/**
2 * Regression guard for the signed-in account menu dropdown stacking on the landing page.
3 *
4 * The dropdown (`.account-menu__dropdown`, `z-index: 5`) lives inside `.masthead`, which is a
5 * positioned stacking context. `.masthead`, `.hero`, and `.showcase` are sibling children of
6 * `.page`. A positioned element's `z-index` only competes within its own stacking context, so the
7 * dropdown's `z-index: 5` is confined to `.masthead` and never escapes it. When `.masthead` and a
8 * later sibling carry the *same* `z-index`, DOM order wins and the later sibling paints on top of
9 * the entire masthead — including the dropdown.
10 *
11 * On mobile the dropdown extends down into the `.hero` region; if `.hero` ties `.masthead` on
12 * `z-index`, the hero text paints over the dropdown (it looks see-through) and the hero intercepts
13 * the dropdown's clicks. So `.masthead` must outrank every sibling it can overlap.
14 *
15 * Rendering the page through astro/container isn't viable here (the runner is pinned to jsdom for
16 * the WordPress block suites), so this pins the stacking order at the source level.
17 */
18import { readFileSync } from 'node:fs';
19import { fileURLToPath } from 'node:url';
20import { describe, expect, it } from 'vitest';
21
22// `import.meta.url` must be referenced from a top-level binding so vite's transform rewrites it
23// to a `file://` URL; referenced inside a `describe` callback it is left as a non-file URL at
24// collection time and `fileURLToPath` throws. (The sibling _index.phase.test.ts does the same.)
25const read = ( rel: string ) =>
26 readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' );
27
28describe( 'landing page masthead stacking', () => {
29 const index = read( './index.astro' );
30 // Strip CSS comments so prose like "z-index: 2" inside a comment can't be mistaken for a
31 // declaration.
32 const style = ( index.match( /<style>([\s\S]*?)<\/style>/ )?.[ 1 ] ?? '' ).replace(
33 /\/\*[\s\S]*?\*\//g,
34 ''
35 );
36
37 /** Read the `z-index` declared on the rule that positions `selector`. */
38 const zIndexOf = ( selector: string ): number => {
39 // `.hero`/`.masthead` also appear in grouped per-phase rules (`… .hero {`) that only set
40 // colours; the positioning rule is the one declaring z-index. Scan every `<selector> { … }`
41 // block (body has no nested braces) and take the z-index wherever it is declared. `\b` keeps
42 // `.masthead` off `.masthead__right`; the leading `\.` keeps it off classes ending the same.
43 const blocks = style.matchAll(
44 new RegExp( `${ selector.replace( /\./g, '\\.' ) }\\b\\s*\\{([^{}]*)\\}`, 'g' )
45 );
46 let z: string | undefined;
47 for ( const [ , body ] of blocks ) {
48 z = body.match( /z-index:\s*(-?\d+)/ )?.[ 1 ] ?? z;
49 }
50 expect( z, `expected a rule positioning ${ selector } with a z-index` ).toBeDefined();
51 return Number( z );
52 };
53
54 it( 'stacks the masthead above the hero so its dropdown can paint over hero content', () => {
55 expect( zIndexOf( '.masthead' ) ).toBeGreaterThan( zIndexOf( '.hero' ) );
56 } );
57
58 it( 'stacks the masthead above the showcase below the fold', () => {
59 expect( zIndexOf( '.masthead' ) ).toBeGreaterThan( zIndexOf( '.showcase' ) );
60 } );
61} );