Skip to content

Commit 3130858

Browse files
committed
feat: add responsive tests for hero layout behavior across various devices
Signed-off-by: Ntege Daniel <danientege785@gmail.com>
1 parent 443c21c commit 3130858

2 files changed

Lines changed: 389 additions & 12 deletions

File tree

e2e/hero.spec.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
/**
4+
* The hero's responsive behaviour, which is entirely a cascade decision and so
5+
* entirely invisible to the vitest suite: every size in it comes out of a
6+
* `clamp`/`min` against `vw`, `svh` and `cqw`, and which of the three layouts is
7+
* live comes out of a media query on the viewport's *shape*. A component test
8+
* sees the class name and learns nothing.
9+
*
10+
* Every case below is a regression that shipped green. The stack ran to 874px
11+
* inside a 678px viewport on an iPad in landscape, because the split layout
12+
* started at 1280 and nothing under it looked at height. A `26vw` wordmark came
13+
* out at 173px on a 375px-tall phone. And the mark's band stopped at the
14+
* container's gutter, where the mask re-tiled and brought the ledger plane back
15+
* past a hard vertical seam.
16+
*/
17+
18+
/** The header spacer in layout.tsx (`h-22.5`), i.e. what the hero has to work with. */
19+
const HEADER = 90;
20+
21+
type Mode = "stack" | "split" | "compact";
22+
23+
interface Case {
24+
name: string;
25+
width: number;
26+
height: number;
27+
mode: Mode;
28+
/**
29+
* Ceiling on hero height as a multiple of the usable viewport. 1 means the
30+
* colophon has to land on the fold, which is the design's own intent and is
31+
* reachable everywhere the mark can sit beside the type. Portrait phones get
32+
* slack instead of a truncated hero: the content is five blocks deep and a
33+
* 667px-tall screen cannot hold it without dropping one.
34+
*/
35+
maxFolds: number;
36+
}
37+
38+
const CASES: Case[] = [
39+
{ name: "iPhone SE", width: 320, height: 568, mode: "stack", maxFolds: 1.7 },
40+
{ name: "iPhone 14", width: 390, height: 844, mode: "stack", maxFolds: 1.15 },
41+
{
42+
name: "iPad portrait",
43+
width: 768,
44+
height: 1024,
45+
mode: "stack",
46+
maxFolds: 1,
47+
},
48+
{
49+
name: 'iPad Pro 12.9" portrait',
50+
width: 1024,
51+
height: 1366,
52+
mode: "stack",
53+
maxFolds: 1,
54+
},
55+
// 1024 wide in both, and they want opposite layouts. This pair is the whole
56+
// reason the split keys on aspect ratio rather than on width.
57+
{
58+
name: "iPad landscape",
59+
width: 1024,
60+
height: 768,
61+
mode: "split",
62+
maxFolds: 1,
63+
},
64+
{ name: "laptop", width: 1280, height: 800, mode: "split", maxFolds: 1 },
65+
{ name: "desktop", width: 1920, height: 1080, mode: "split", maxFolds: 1 },
66+
{
67+
name: "short window",
68+
width: 900,
69+
height: 600,
70+
mode: "split",
71+
maxFolds: 1.05,
72+
},
73+
{
74+
name: "phone landscape",
75+
width: 667,
76+
height: 375,
77+
mode: "compact",
78+
maxFolds: 1.3,
79+
},
80+
{
81+
name: "large phone landscape",
82+
width: 844,
83+
height: 390,
84+
mode: "compact",
85+
maxFolds: 1.3,
86+
},
87+
];
88+
89+
interface Box {
90+
top: number;
91+
right: number;
92+
bottom: number;
93+
left: number;
94+
width: number;
95+
height: number;
96+
}
97+
98+
interface Geometry {
99+
clientWidth: number;
100+
scrollWidth: number;
101+
hero: Box;
102+
scene: Box | null;
103+
emblem: Box | null;
104+
title: Box;
105+
/**
106+
* How far right the wordmark's glyphs actually reach. The element's own box is
107+
* the whole column and says nothing — "Hiero" sets to 2.2x its font size, so
108+
* at 216px it uses 476px of a 700px column.
109+
*
110+
* Horizontally a range rect is exactly the advance width. Vertically it is
111+
* not: Chrome measures the font's ascent and descent rather than the
112+
* `leading-[0.78]` line box, which puts its top edge some 80px above the cap
113+
* at display sizes. So this is only ever asked a horizontal question.
114+
*/
115+
titleInkRight: number;
116+
}
117+
118+
async function measure(page: Page): Promise<Geometry> {
119+
return page.evaluate(() => {
120+
const rect = ({
121+
top,
122+
right,
123+
bottom,
124+
left,
125+
width,
126+
height,
127+
}: DOMRect): Box => ({ top, right, bottom, left, width, height });
128+
const box = (selector: string): Box | null => {
129+
const element = document.querySelector(selector);
130+
return element ? rect(element.getBoundingClientRect()) : null;
131+
};
132+
133+
const range = document.createRange();
134+
range.selectNodeContents(document.querySelector(".hero-section-title")!);
135+
136+
return {
137+
clientWidth: document.documentElement.clientWidth,
138+
scrollWidth: document.documentElement.scrollWidth,
139+
hero: box(".hero-section")!,
140+
scene: box(".hero-mark-scene"),
141+
emblem: box(".hero-mark-emblem"),
142+
title: box(".hero-section-title")!,
143+
titleInkRight: range.getBoundingClientRect().right,
144+
};
145+
});
146+
}
147+
148+
for (const { name, width, height, mode, maxFolds } of CASES) {
149+
test.describe(`${name} (${width}x${height})`, () => {
150+
test.use({ viewport: { width, height }, reducedMotion: "reduce" });
151+
152+
test(`lays the hero out as ${mode} and keeps it near the fold`, async ({
153+
page,
154+
}) => {
155+
await page.goto("/");
156+
const {
157+
clientWidth,
158+
scrollWidth,
159+
hero,
160+
scene,
161+
emblem,
162+
title,
163+
titleInkRight,
164+
} = await measure(page);
165+
166+
// The traces and the mark both bleed past the frame on purpose. They are
167+
// allowed to, as long as the section clips them instead of the document
168+
// growing a sideways scrollbar.
169+
expect(scrollWidth, "document scrolls sideways").toBeLessThanOrEqual(
170+
clientWidth,
171+
);
172+
173+
const usable = height - HEADER;
174+
expect(
175+
hero.height,
176+
`hero is ${Math.round(hero.height)}px against ${usable}px of viewport`,
177+
).toBeLessThanOrEqual(usable * maxFolds);
178+
179+
expect(scene, "the mark scene is missing").not.toBeNull();
180+
expect(emblem, "the mark card is missing").not.toBeNull();
181+
182+
if (mode === "stack") {
183+
// A band in the flow: shorter than the hero, and the card fits inside
184+
// it. At `min(13rem,56vw)` the card was 208px in a 192px band and hung
185+
// out of both edges.
186+
expect(scene!.height).toBeLessThan(hero.height);
187+
expect(emblem!.top).toBeGreaterThanOrEqual(scene!.top - 2);
188+
expect(emblem!.bottom).toBeLessThanOrEqual(scene!.bottom + 2);
189+
190+
// The band reaches the viewport's edge. Ending it at the container's
191+
// gutter is what left a hard seam in the ledger plane, because a mask
192+
// is also a clip and this one re-tiled past the box.
193+
expect(
194+
scene!.right,
195+
"the band stops short of the frame and will show a seam",
196+
).toBeGreaterThanOrEqual(clientWidth - 1);
197+
198+
// Stacked, the two subjects are separated by the band's bottom edge, so
199+
// the question is vertical: the wordmark's column starts below it.
200+
expect(
201+
title.top,
202+
"the wordmark starts inside the mark's band",
203+
).toBeGreaterThanOrEqual(scene!.bottom);
204+
} else {
205+
// A panel beside the type: full-height, so it costs the column nothing.
206+
expect(scene!.height).toBeCloseTo(hero.height, 0);
207+
expect(scene!.top).toBeCloseTo(hero.top, 0);
208+
209+
// Side by side, the question is horizontal, and it is the one the
210+
// `44cqw` cap on the wordmark exists to answer.
211+
expect(
212+
titleInkRight,
213+
"the wordmark runs into the mark",
214+
).toBeLessThanOrEqual(emblem!.left);
215+
}
216+
});
217+
});
218+
}

0 commit comments

Comments
 (0)