// views.mjs — switching views holds still (R2, Paul: "views jumping"). // // Driven by TestViewSwitchingHoldsStillInARealBrowser, which seeds the // week of 21 September 2026 with an all-day event and timed events. // // At 1440 and 390 wide, over every hop Day → Week → Month → Agenda → Day // and every view's Previous and Next: // 1. CLS inside the new document is 0. // 2. The page heading, the view switcher, Quick add, New event, and the // Previous and Next buttons are at the same x/y as on the page // before (within 0.05px: the bold current tab once moved the switcher by 0.1px). // 3. Every view's
has the same box, so there is one layout. // And, at 1440: // 4. Week and Month are not squeezed: a day column is at least 160px. // 5. The week's sticky day headings sit at the top of the grid's own // scroll box once it has scrolled to working hours, painted over // the hours scrolled beneath them, with the all-day row still shown. import { chromium } from 'playwright'; const [origin, cookieName, cookieValue] = process.argv.slice(2); if (!origin || !cookieName || !cookieValue) { console.error('usage: views.mjs '); process.exit(2); } const failures = []; function check(ok, what) { if (ok) { console.log('ok: ' + what); return; } failures.push(what); console.error('FAIL: ' + what); } const views = { Day: '/day/2026-09-24', Week: '/week/2026-09-24', Month: '/month/2026-09', Agenda: '/agenda?from=2026-09-24', }; const fields = ['heading', 'switcher', 'quickAdd', 'newEvent', 'prev', 'next', 'main']; const browser = await chromium.launch(); for (const [width, height] of [[1440, 900], [390, 844]]) { const ctx = await browser.newContext({ viewport: { width, height } }); await ctx.addCookies([{ name: cookieName, value: cookieValue, url: origin, httpOnly: true }]); await ctx.addInitScript(() => { window.__cls = 0; new PerformanceObserver((list) => { for (const e of list.getEntries()) if (!e.hadRecentInput) window.__cls += e.value; }).observe({ type: 'layout-shift', buffered: true }); }); const page = await ctx.newPage(); const facts = async () => { await page.waitForLoadState('networkidle'); await page.waitForTimeout(300); return page.evaluate(() => { const box = (e) => { if (!e) return null; const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 ? { x: r.x, y: r.y, w: r.width } : null; }; const bar = [...document.querySelectorAll('[cal-bar] a')]; return { cls: window.__cls || 0, heading: box(document.querySelector('main h1')), switcher: box(document.querySelector('[rst-seg-tabs][aria-label="Calendar view"]')), quickAdd: box(document.querySelector('[cal-quick-summary]')), newEvent: box(document.querySelector('[cal-header-actions] a[rst-btn="primary"]')), prev: box(bar.find((a) => /^Previous/.test(a.textContent.trim()))), next: box(bar.find((a) => /^Next/.test(a.textContent.trim()))), main: box(document.querySelector('main')), }; }); }; const hop = async (name, path) => { const before = await facts(); await page.goto(origin + path); const after = await facts(); check(after.cls === 0, `${width}: ${name}: CLS is 0 (${after.cls})`); for (const f of fields) { const a = before[f], b = after[f]; const still = a && b && Math.abs(a.x - b.x) < 0.05 && Math.abs(a.y - b.y) < 0.05 && (f !== 'main' || Math.abs(a.w - b.w) < 0.05); check(still, `${width}: ${name}: ${f} holds still (${a ? `${a.x.toFixed(1)},${a.y.toFixed(1)}` : 'missing'} → ${b ? `${b.x.toFixed(1)},${b.y.toFixed(1)}` : 'missing'})`); } }; await page.goto(origin + views.Day); await hop('Day → Week', views.Week); await hop('Week → Month', views.Month); await hop('Month → Agenda', views.Agenda); await hop('Agenda → Day', views.Day); for (const [view, path] of Object.entries(views)) { for (const dir of ['Previous', 'Next']) { await page.goto(origin + path); await facts(); const href = await page.locator('[cal-bar] a').filter({ hasText: new RegExp('^' + dir) }).first().getAttribute('href'); await hop(`${view} ${dir.toLowerCase()}`, href); } } if (width === 1440) { await page.goto(origin + views.Week); await facts(); const week = await page.evaluate(() => { const heads = [...document.querySelectorAll('[cal-column-head]')].slice(1).map((h) => h.getBoundingClientRect()); const scroller = document.querySelector('[cal-week-scroll]'); const top = scroller.getBoundingClientRect().top; // What paints at each heading's centre: the heading, not an hour // row scrolled over it. const covered = heads.filter((r) => !document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.closest('[cal-column-head]')).length; // The all-day row stays on the first screen once the grid has // scrolled to working hours: the offsite's chip paints at its centre. const chip = [...document.querySelectorAll('[cal-allday] [cal-chip]')].find((c) => c.textContent.includes('Team offsite')); const cr = chip && chip.getBoundingClientRect(); const allDay = !!(cr && document.elementFromPoint(cr.x + cr.width / 2, cr.y + cr.height / 2)?.closest('[cal-chip]') === chip); return { col: Math.min(...heads.map((r) => r.width)), headTop: heads[0].top, top, scrolled: scroller.scrollTop, covered, allDay }; }); check(week.col >= 160, `1440: a week column is at least 160px (${week.col.toFixed(1)})`); check(week.scrolled > 0, `1440: the week opened scrolled to working hours (scrollTop ${week.scrolled})`); check(Math.abs(week.headTop - week.top) < 1, `1440: the day headings stick to the grid's top (${week.headTop.toFixed(1)} vs ${week.top.toFixed(1)})`); check(week.allDay, '1440: the all-day row is still on screen after the scroll to working hours'); check(week.covered === 0, `1440: every day heading paints over the scrolled grid (${week.covered} covered)`); await page.goto(origin + views.Month); await facts(); const cell = await page.evaluate(() => Math.min(...[...document.querySelectorAll('[cal-month] tbody tr:first-child td')].map((td) => td.getBoundingClientRect().width))); check(cell >= 160, `1440: a month column is at least 160px (${cell.toFixed(1)})`); } await ctx.close(); } await browser.close(); if (failures.length) { console.error(`\n${failures.length} check(s) failed`); process.exit(1); }