Skip to content

Commit a52b78f

Browse files
Add guide and evals for context-sensitive sticky headers with hardened grader
1 parent 28539c1 commit a52b78f

5 files changed

Lines changed: 502 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- The header container uses `position: sticky` to remain at the top of the scroller.
2+
- The header container defines `container-type: scroll-state` to enable scroll state queries.
3+
- The header container defines `container-name: section-header` to avoid collisions.
4+
- The header visual style changes (both background color and padding) when it is stuck at the top.
5+
- The visual style changes are implemented using the `@container scroll-state(stuck: top)` query.
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import { test as base, expect } from '@playwright/test';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
5+
// Fixture setup within the same file to ensure it's self-contained
6+
const test = base.extend<{ TARGET_URL: string }>({
7+
TARGET_URL: async ({}, use) => {
8+
await use('http://localhost/');
9+
},
10+
});
11+
12+
// Setup
13+
const targetFile = process.env.TARGET_FILE;
14+
if (!targetFile) {
15+
throw new Error('TARGET_FILE environment variable not set.');
16+
}
17+
18+
const filePath = path.resolve(targetFile);
19+
const targetDir = path.dirname(filePath);
20+
const demoName = path.basename(filePath);
21+
22+
test.describe(`Context-Sensitive Sticky Headers Expectations: ${demoName}`, () => {
23+
24+
test.beforeEach(async ({ page, TARGET_URL }) => {
25+
if (TARGET_URL.startsWith('http://localhost/')) {
26+
await page.route('http://localhost/**', async (route) => {
27+
const requestPath = new URL(route.request().url()).pathname;
28+
const localFilePath = path.join(targetDir, requestPath === '/' ? demoName : requestPath);
29+
30+
if (fs.existsSync(localFilePath)) {
31+
await route.fulfill({ path: localFilePath });
32+
} else {
33+
await route.continue();
34+
}
35+
});
36+
}
37+
await page.goto(TARGET_URL);
38+
});
39+
40+
test('Header container should use position: sticky', async ({ page }) => {
41+
const elements = page.locator('.sticky-container');
42+
const count = await elements.count();
43+
expect(count).toBeGreaterThan(0);
44+
for (let i = 0; i < count; i++) {
45+
const position = await elements.nth(i).evaluate(el => getComputedStyle(el).position);
46+
expect(position).toBe('sticky');
47+
}
48+
});
49+
50+
test('Header container should define container-type: scroll-state', async ({ page }) => {
51+
const elements = page.locator('.sticky-container');
52+
const count = await elements.count();
53+
expect(count).toBeGreaterThan(0);
54+
for (let i = 0; i < count; i++) {
55+
const containerType = await elements.nth(i).evaluate(el => {
56+
// @ts-ignore
57+
return getComputedStyle(el).containerType || getComputedStyle(el).getPropertyValue('container-type');
58+
});
59+
expect(containerType).toContain('scroll-state');
60+
}
61+
});
62+
63+
test('Header container should define container-name: section-header', async ({ page }) => {
64+
const elements = page.locator('.sticky-container');
65+
const count = await elements.count();
66+
expect(count).toBeGreaterThan(0);
67+
for (let i = 0; i < count; i++) {
68+
const containerName = await elements.nth(i).evaluate(el => {
69+
// @ts-ignore
70+
return getComputedStyle(el).containerName || getComputedStyle(el).getPropertyValue('container-name');
71+
});
72+
expect(containerName).toBe('section-header');
73+
}
74+
});
75+
76+
test('Header visual style should change when stuck at the top', async ({ page }) => {
77+
const header = page.locator('.sticky-header').first();
78+
79+
// Ensure we start at the top (unstuck)
80+
await page.evaluate(() => window.scrollTo(0, 0));
81+
82+
// Wait for header to be at its natural position (top > 0)
83+
await page.waitForFunction(() => {
84+
const el = document.querySelector('.sticky-header');
85+
if (!el) return false;
86+
return el.getBoundingClientRect().top > 0;
87+
}, { timeout: 5000 });
88+
89+
const headerRect = await header.evaluate(el => {
90+
const r = el.getBoundingClientRect();
91+
return { top: r.top + window.scrollY, height: r.height };
92+
});
93+
94+
const initialStyles = await header.evaluate(el => {
95+
const style = getComputedStyle(el);
96+
return {
97+
backgroundColor: style.backgroundColor,
98+
paddingTop: parseFloat(style.paddingTop),
99+
paddingBottom: parseFloat(style.paddingBottom),
100+
};
101+
});
102+
103+
// Assert header is actually unstuck
104+
expect(headerRect.top).toBeGreaterThan(0);
105+
106+
// Scroll to make it stick (past its natural position)
107+
await page.evaluate((args) => {
108+
window.scrollTo(0, args.top + args.height + 50);
109+
}, headerRect);
110+
111+
// Wait for style change (polling)
112+
await page.waitForFunction((args) => {
113+
const el = document.querySelector(args.selector);
114+
if (!el) return false;
115+
const style = getComputedStyle(el);
116+
const pt = parseFloat(style.paddingTop);
117+
const pb = parseFloat(style.paddingBottom);
118+
119+
// Check for blue-ish color (high blue channel)
120+
const rgb = style.backgroundColor.match(/\d+/g);
121+
const isBlue = rgb ? (Number(rgb[2]) > Number(rgb[0]) && Number(rgb[2]) > Number(rgb[1]) && Number(rgb[2]) > 100) : false;
122+
123+
// Check that background changed AND padding decreased AND it is blue
124+
return style.backgroundColor !== args.initialBg &&
125+
(pt < args.initialPt || pb < args.initialPb) &&
126+
isBlue;
127+
}, {
128+
selector: '.sticky-header',
129+
initialBg: initialStyles.backgroundColor,
130+
initialPt: initialStyles.paddingTop,
131+
initialPb: initialStyles.paddingBottom
132+
}, { timeout: 5000 });
133+
134+
const stuckStyles = await header.evaluate(el => {
135+
const style = getComputedStyle(el);
136+
return {
137+
backgroundColor: style.backgroundColor,
138+
paddingTop: parseFloat(style.paddingTop),
139+
paddingBottom: parseFloat(style.paddingBottom),
140+
};
141+
});
142+
143+
// Assert specific changes
144+
expect(stuckStyles.paddingTop).toBeLessThan(initialStyles.paddingTop);
145+
expect(stuckStyles.backgroundColor).not.toBe(initialStyles.backgroundColor);
146+
147+
const rgb = stuckStyles.backgroundColor.match(/\d+/g);
148+
const isBlue = rgb ? (Number(rgb[2]) > Number(rgb[0]) && Number(rgb[2]) > Number(rgb[1]) && Number(rgb[2]) > 100) : false;
149+
expect(isBlue).toBe(true);
150+
151+
// Scroll back to 0 and assert revert
152+
await page.evaluate(() => window.scrollTo(0, 0));
153+
154+
// Wait for style to revert
155+
await page.waitForFunction((args) => {
156+
const el = document.querySelector(args.selector);
157+
if (!el) return false;
158+
const style = getComputedStyle(el);
159+
const pt = parseFloat(style.paddingTop);
160+
const pb = parseFloat(style.paddingBottom);
161+
return style.backgroundColor === args.initialBg &&
162+
pt === args.initialPt &&
163+
pb === args.initialPb;
164+
}, {
165+
selector: '.sticky-header',
166+
initialBg: initialStyles.backgroundColor,
167+
initialPt: initialStyles.paddingTop,
168+
initialPb: initialStyles.paddingBottom
169+
}, { timeout: 5000 });
170+
});
171+
172+
test('Visual style changes should be implemented using @container scroll-state(...)', async ({ page }) => {
173+
const hasScrollStateQuery = await page.evaluate(() => {
174+
const sheets = Array.from(document.styleSheets);
175+
return sheets.some(sheet => {
176+
try {
177+
const rules = Array.from(sheet.cssRules);
178+
return rules.some(rule => {
179+
// @ts-ignore - conditionText might not be on all rules
180+
const condition = rule.conditionText || '';
181+
const normalized = condition.replace(/\s+/g, '');
182+
const hasScrollState = normalized.includes('scroll-state');
183+
const hasValidStuck = normalized.includes('stuck:top') ||
184+
normalized.includes('stuck:inset-block-start') ||
185+
normalized.includes('stuck:inset-inline-start');
186+
187+
if (hasScrollState && hasValidStuck) {
188+
return true;
189+
}
190+
191+
// Some browsers might not put it in conditionText but we can check the constructor name or other props
192+
if (rule.constructor.name === 'CSSContainerRule') {
193+
const cssText = rule.cssText.replace(/\s+/g, '');
194+
const hasCssScrollState = cssText.includes('scroll-state');
195+
const hasCssValidStuck = cssText.includes('stuck:top') ||
196+
cssText.includes('stuck:inset-block-start') ||
197+
cssText.includes('stuck:inset-inline-start');
198+
return hasCssScrollState && hasCssValidStuck;
199+
}
200+
return false;
201+
});
202+
} catch (e) {
203+
return false;
204+
}
205+
});
206+
});
207+
expect(hasScrollStateQuery).toBe(true);
208+
});
209+
210+
test('Stuck styles must come from the container query, not JS', async ({ page }) => {
211+
const header = page.locator('.sticky-header').first();
212+
213+
const headerRect = await header.evaluate(el => {
214+
const r = el.getBoundingClientRect();
215+
return { top: r.top + window.scrollY, height: r.height };
216+
});
217+
218+
const initialStyles = await header.evaluate(el => {
219+
const style = getComputedStyle(el);
220+
return {
221+
backgroundColor: style.backgroundColor,
222+
paddingTop: parseFloat(style.paddingTop),
223+
};
224+
});
225+
226+
// Scroll to stick (past its natural position)
227+
await page.evaluate((args) => {
228+
window.scrollTo(0, args.top + args.height + 50);
229+
}, headerRect);
230+
231+
// Wait for style change
232+
await page.waitForFunction((args) => {
233+
const el = document.querySelector(args.selector);
234+
if (!el) return false;
235+
const style = getComputedStyle(el);
236+
return style.backgroundColor !== args.initialBg;
237+
}, { selector: '.sticky-header', initialBg: initialStyles.backgroundColor }, { timeout: 5000 });
238+
239+
// Delete the container query rule (recursively)
240+
await page.evaluate(() => {
241+
function deleteRuleRecursive(ruleList: any, sheetOrParent: any) {
242+
for (let i = ruleList.length - 1; i >= 0; i--) {
243+
const r = ruleList[i];
244+
// @ts-ignore
245+
const condition = r.conditionText || '';
246+
const normalized = condition.replace(/\s+/g, '');
247+
248+
const hasValidStuck = normalized.includes('stuck:top') ||
249+
normalized.includes('stuck:inset-block-start') ||
250+
normalized.includes('stuck:inset-inline-start');
251+
252+
if (normalized.includes('scroll-state') && hasValidStuck) {
253+
// Delete the rule from its parent list
254+
// @ts-ignore
255+
sheetOrParent.deleteRule(i);
256+
} else if (r.cssRules) {
257+
deleteRuleRecursive(r.cssRules, r);
258+
}
259+
}
260+
}
261+
262+
for (const sheet of Array.from(document.styleSheets)) {
263+
try {
264+
deleteRuleRecursive(sheet.cssRules, sheet);
265+
} catch {}
266+
}
267+
});
268+
269+
// Assert styles revert to initial
270+
await page.waitForFunction((args) => {
271+
const el = document.querySelector(args.selector);
272+
if (!el) return false;
273+
const style = getComputedStyle(el);
274+
const pt = parseFloat(style.paddingTop);
275+
return style.backgroundColor === args.initialBg && pt === args.initialPt;
276+
}, {
277+
selector: '.sticky-header',
278+
initialBg: initialStyles.backgroundColor,
279+
initialPt: initialStyles.paddingTop
280+
}, { timeout: 5000 });
281+
282+
// Second arm: scroll away and back and verify it DOES NOT re-acquire stuck styles
283+
await page.evaluate(() => window.scrollTo(0, 0));
284+
await page.waitForTimeout(300); // let any scroll handler run
285+
286+
await page.evaluate((args) => {
287+
window.scrollTo(0, args.top + args.height + 50);
288+
}, headerRect);
289+
await page.waitForTimeout(300);
290+
291+
const stillHasStuckLook = await header.evaluate((el, args) => {
292+
const style = getComputedStyle(el);
293+
return style.backgroundColor !== args.initialBg;
294+
}, { initialBg: initialStyles.backgroundColor });
295+
296+
expect(stillHasStuckLook).toBe(false);
297+
}); test('Existing navigation bar should remain intact', async ({ page }) => {
298+
const nav = page.locator('nav').first();
299+
await expect(nav).toBeVisible();
300+
301+
const logo = page.locator('nav .logo');
302+
await expect(logo).toBeVisible();
303+
304+
const containerType = await nav.evaluate(el => {
305+
// @ts-ignore
306+
return getComputedStyle(el).containerType || getComputedStyle(el).getPropertyValue('container-type');
307+
});
308+
expect(containerType).not.toContain('scroll-state');
309+
});
310+
311+
});

0 commit comments

Comments
 (0)