A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1/**
2 * Regression guard for the landing-page sky phase carrier (docs/specs/sp8-brand-first-light.md).
3 *
4 * `data-phase` lives on exactly one carrier: `<html>`, the only element the pre-paint inline head
5 * script can reach (it runs in `<head>`, before `.page` exists, so it sets `documentElement`).
6 *
7 * The catch: the per-phase rules — `[data-phase='x'] .sky` / `.masthead` / `.hero` — live in
8 * index.astro's *scoped* `<style>`. Astro appends index.astro's scope id to every part of a
9 * selector, including the ancestor, yielding `[cid][data-phase='x'] .sky[cid]`. But `<html>` is
10 * rendered by Base.astro and never carries index.astro's cid, so a bare (scoped) ancestor matches
11 * nothing and the sky renders unstyled. Wrapping the ancestor in `:global([data-phase='x'])` keeps
12 * the descendant (`.sky`/`.masthead`/`.hero`) scoped while letting the ancestor match `<html>`.
13 *
14 * These asserts pin the wiring at the source level — rendering the page through astro/container
15 * isn't viable here (the test runner is pinned to jsdom for the WordPress block suites, which
16 * breaks esbuild's init invariant).
17 */
18import { readFileSync } from 'node:fs';
19import { fileURLToPath } from 'node:url';
20import { describe, expect, it } from 'vitest';
21import { phaseForHour } from '../lib/landing/time-of-day';
22
23const read = ( rel: string ) =>
24 readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' );
25
26/** Strip the `---` frontmatter and any <style>/<script> blocks, leaving only rendered markup. */
27const markupOnly = ( astro: string ) =>
28 astro
29 .replace( /^---[\s\S]*?\n---/, '' )
30 .replace( /<style[\s\S]*?<\/style>/g, '' )
31 .replace( /<script[\s\S]*?<\/script>/g, '' );
32
33describe( 'landing page sky phase carrier', () => {
34 const index = read( './index.astro' );
35 const base = read( '../layouts/Base.astro' );
36
37 it( 'carries the phase on <html> (the no-JS / pre-paint default the head script overwrites)', () => {
38 const htmlTag = markupOnly( base ).match( /<html\b[^>]*>/ )?.[ 0 ];
39 expect( htmlTag ).toBeDefined();
40 expect( htmlTag ).toMatch( /data-phase=/ );
41 } );
42
43 it( 'passes the default phase from the landing page into the layout', () => {
44 expect( index ).toMatch( /phase=\{\s*DEFAULT_PHASE\s*\}/ );
45 } );
46
47 it( 'never puts data-phase on a body element that would shadow <html> in the cascade', () => {
48 // Every element in the page markup is a descendant of <html>; none may carry its own
49 // data-phase. (The head <script> assigns it via documentElement, never as markup.)
50 expect( markupOnly( index ) ).not.toMatch( /data-phase=/ );
51 } );
52
53 it( 'updates the phase before first paint via documentElement', () => {
54 expect( index ).toMatch( /document\.documentElement\.dataset\.phase\s*=/ );
55 } );
56
57 it( 'wraps every per-phase ancestor selector in :global() so it can match <html>', () => {
58 // The phase carrier is <html> (rendered by Base.astro), which never carries index.astro's
59 // scoped cid. A bare `[data-phase=...]` ancestor would be scoped to that cid and match
60 // nothing; only `:global([data-phase=...])` reaches <html>. Assert that EVERY data-phase
61 // selector is global-wrapped — a single scoped one silently blanks the sky.
62 const style = index.match( /<style>([\s\S]*?)<\/style>/ )?.[ 1 ] ?? '';
63 const total = ( style.match( /\[data-phase=/g ) ?? [] ).length;
64 const global = ( style.match( /:global\(\s*\[data-phase=/g ) ?? [] ).length;
65 expect( total, 'expected per-phase selectors in the landing-page styles' ).toBeGreaterThan( 0 );
66 expect( global, 'every [data-phase=...] selector must be :global()-wrapped' ).toBe( total );
67 } );
68
69 // At night the sky is the bare starfield: the sun-driven layers (.bloom glow, .halo ring,
70 // .horizon line) must be switched off so no sun gradient or halo bleeds into the dark sky.
71 it( 'hides every sun layer at night, leaving only the stars', () => {
72 const style = index.match( /<style>([\s\S]*?)<\/style>/ )?.[ 1 ] ?? '';
73 for ( const layer of [ 'bloom', 'halo', 'horizon' ] ) {
74 const rule = new RegExp(
75 `:global\\(\\s*\\[data-phase='night'\\]\\s*\\)\\s*\\.${ layer }\\b`
76 );
77 expect( style, `night must silence the .${ layer } sun layer` ).toMatch( rule );
78 }
79 } );
80
81 // The inline head script must run before any module loads (for a no-flash sky), so it
82 // can't import phaseForHour — it hand-mirrors the same hour->phase boundaries. This guard
83 // keeps that copy honest: extract its `var p = <ternary>` expression straight from source
84 // and assert it agrees with phaseForHour for every hour. (The source comment in
85 // time-of-day.ts promises exactly this test.)
86 it( 'mirrors the phaseForHour boundaries in the inline head script', () => {
87 const expr = index.match( /var p\s*=\s*([\s\S]*?);/ )?.[ 1 ];
88 expect( expr, 'inline head script should assign `var p = <phase ternary>;`' ).toBeDefined();
89 // Lock the expression to a phase ternary over `h` before evaluating it: only the variable
90 // `h`, integer literals, comparison/logical/ternary operators, and quoted phase words are
91 // allowed. This makes the `new Function` below incapable of running anything else, even if
92 // the source were ever changed to something unexpected.
93 expect( expr, 'unexpected tokens in inline phase expression' ).toMatch(
94 /^[\sa-z\d<>=|?:'"]+$/
95 );
96 // eslint-disable-next-line no-new-func -- expr is validated above to be a pure phase ternary.
97 const scriptPhase = new Function( 'h', `return ( ${ expr } );` ) as ( h: number ) => string;
98 for ( let h = 0; h < 24; h++ ) {
99 expect( scriptPhase( h ), `hour ${ h }` ).toBe( phaseForHour( h ) );
100 }
101 } );
102} );