Files
hanzo.ai/scripts/origami.mjs

115 lines
4.1 KiB
JavaScript

// Shoot the workspace in every state that matters, so the layering can be
// JUDGED rather than asserted. One file per state, named by the state, small
// JPEGs because the question is "does this read as stacked paper" and that
// survives compression.
//
// node scripts/origami.mjs [out-dir] [filter]
//
// The filter is a substring of the shot name — `node scripts/origami.mjs out chat`
// re-shoots only /chat while iterating.
import { chromium } from '@playwright/test'
import { mkdir } from 'node:fs/promises'
const OUT = process.argv[2] ?? '/home/z/Desktop/origami'
const ONLY = process.argv[3] ?? ''
const HOST = 'http://localhost:3000'
const SIZES = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'phone', width: 390, height: 844 },
]
const ROUTES = ['/chat', '/dev', '/work']
const THEMES = ['dark', 'light']
/** The drawer button, by its accessible name — the one handle that does not
* depend on which icon the rail happens to draw, or which ELEMENT it renders
* (gui gives it `role="button"` on a div, so a `button[…]` selector finds
* nothing and the open state silently never gets shot). */
const RAIL = '[aria-label="Open sidebar"]'
/** `next dev` recompiles a route on first hit and aborts the navigation that
* triggered it. That is the dev server working, not the page failing. */
async function open(page, url) {
for (let tries = 3; ; tries--) {
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120_000 })
return
} catch (error) {
if (tries === 1) throw error
await page.waitForTimeout(2500)
}
}
}
async function shoot(page, name) {
if (ONLY && !name.includes(ONLY)) return
await page.screenshot({ path: `${OUT}/${name}.jpg`, type: 'jpeg', quality: 72 })
const overflow = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
}))
const bad = overflow.scroll > overflow.client + 1
console.log(`${bad ? 'OVERFLOW' : ' ok '} ${name} ${overflow.scroll}/${overflow.client}`)
return bad
}
const main = async () => {
await mkdir(OUT, { recursive: true })
const browser = await chromium.launch()
let overflowed = 0
for (const theme of THEMES) {
for (const size of SIZES) {
const context = await browser.newContext({
viewport: { width: size.width, height: size.height },
colorScheme: theme,
deviceScaleFactor: 1,
})
// @hanzo/appearance's head script replays what was last painted before
// first paint, so the page never renders in the wrong theme and then
// corrects itself in a screenshot; the preference is what the mount keeps.
await context.addInitScript((t) => {
try {
window.localStorage.setItem('hanzo.appearance', JSON.stringify({ theme: t }))
window.localStorage.setItem('hanzo.appearance.painted', JSON.stringify({ vars: {}, theme: t }))
} catch {}
}, theme)
if (ONLY && !`${size.name}-${theme}`.split('-').some((part) => ONLY.includes(part))) {
await context.close()
continue
}
const page = await context.newPage()
for (const route of ROUTES) {
await open(page, `${HOST}${route}`)
// gui writes its atomic sheet on first render; a shot taken before that
// is a picture of unstyled HTML and proves nothing.
await page.waitForTimeout(1400)
const slug = route.replace(/\W/g, '') || 'home'
const stem = `${slug}-${size.name}-${theme}`
if (await shoot(page, `${stem}-rail-closed`)) overflowed++
const toggle = page.locator(RAIL).first()
if (await toggle.count()) {
await toggle.click()
await page.waitForTimeout(500)
if (await shoot(page, `${stem}-rail-open`)) overflowed++
await toggle.click()
await page.waitForTimeout(400)
}
}
await context.close()
}
}
await browser.close()
console.log(overflowed ? `\n${overflowed} state(s) scroll sideways` : '\nno state scrolls sideways')
process.exit(overflowed ? 1 : 0)
}
main()