Files
hanzo.ai/e2e/gates/shell-gaps.spec.ts
zeekay aa3d0e665a deps: @hanzo/app 0.2.35, and the gate that measures the shell's gaps and lines
One gap above, at the end and below the pane; the head and foot marks share
a centerline; on a phone the bar and the drawer share one. Read off the
laid-out page at 1440x900, 1384x1100 and 390x844, light and dark.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 01:41:13 -07:00

178 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test'
import { serveExport } from './export'
import { signIn } from '../session'
/**
* THE SHELL'S GAPS AND LINES, MEASURED.
*
* The pane floats on the frame's ground and leaves ONE gap above, at its end and
* below (`--pane-gap`); the marks in a head band share one centerline (the rail's
* first mark, the sidebar's title row, the pane's top bar), and at the foot the
* rail's account and the sidebar's Upgrade plan share another. On a phone the
* bar leaves the same gap above it, its row stands on the line of the drawer's
* title row, and the drawer's foot stands on the composer's. Every edge is read
* off the laid-out page, in a bounding box, within a pixel.
*/
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
const ONE = 1
async function open(page: Page, path: string, size: { width: number; height: number }, scheme: 'dark' | 'light') {
await page.emulateMedia({ colorScheme: scheme })
await signIn(page, { name: 'Antje', orgs: [{ org: 'webby-ai', role: 'admin' }] })
await page.addInitScript(() => localStorage.setItem('hanzo_iam_current_org', 'webby-ai'))
await page.route(
(u) => u.pathname.startsWith('/v1/'),
(route) => {
const at = new URL(route.request().url()).pathname
// The free plan, so the sidebar's foot carries Upgrade plan.
if (at === '/v1/billing/tier') return route.fulfill({ json: { plan: 'free', tier: { name: 'free' } } })
if (at === '/v1/agents/chat/conversations')
return route.fulfill({ json: { conversations: [{ id: 't-1', title: 'Aurora', updatedAt: new Date(Date.now() - 120_000).toISOString() }] } })
return route.fulfill({ json: {} })
},
)
await page.setViewportSize(size)
await page.goto(`${base}${path}`)
await page.locator('[data-slot="frame"]').waitFor({ timeout: 30_000 })
await page.getByRole('dialog', { name: 'Cookie preferences' }).getByRole('button', { name: 'OK' }).click({ timeout: 4000 }).catch(() => {})
await page.locator('main[data-slot="pane"]').first().waitFor()
await page.waitForTimeout(1200)
}
interface Box {
x: number
y: number
b: number
r: number
cy: number
}
/** The first laid-out box matching, or null. */
const box = (page: Page, selector: string) =>
page.evaluate((sel): Box | null => {
for (const n of document.querySelectorAll(sel)) {
const r = n.getBoundingClientRect()
if (r.width && r.height) return { x: r.x, y: r.y, b: r.y + r.height, r: r.x + r.width, cy: r.y + r.height / 2 }
}
return null
}, selector)
const must = async (page: Page, selector: string): Promise<Box> => {
const found = await box(page, selector)
expect(found, `${selector} is drawn`).not.toBeNull()
return found!
}
const near = (a: number, b: number, why: string) => expect(Math.abs(a - b), `${why}: ${a} vs ${b}`).toBeLessThanOrEqual(ONE)
const SCHEMES = ['dark', 'light'] as const
for (const scheme of SCHEMES) {
for (const size of [{ width: 1440, height: 900 }, { width: 1384, height: 1100 }]) {
for (const path of ['/chat', '/dev', '/beta']) {
test(`${scheme} ${size.width}x${size.height} ${path}: the pane leaves one gap above, at its end and below, and the head and foot lines are shared`, async ({ page }) => {
await open(page, path, size, scheme)
const frame = await must(page, '[data-slot="frame"]')
const pane = await must(page, 'main[data-slot="pane"]')
const gap = await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('[data-slot="frame"]')!).paddingBottom))
expect(gap, 'the gap is the token’s').toBeGreaterThan(0)
near(pane.y - frame.y, gap, 'the gap above the pane')
near(frame.r - pane.r, gap, 'the gap at its end')
near(frame.b - pane.b, gap, 'the gap below it')
// The head line: the rail's first mark, the sidebar's title row and the pane's top bar.
const head = await box(page, '[data-slot="rail"] button')
near(head!.cy, pane.y + 26, 'the rail’s first mark on the head band’s line')
if (path !== '/beta') {
const title = await must(page, '[data-slot="app-head"]')
near(title.cy, head!.cy, 'the sidebar’s title row on the rail’s first mark')
}
if (path === '/chat') {
const controls = await must(page, '[data-slot="conversation-controls"]')
near(controls.cy, head!.cy, 'the pane’s top bar on the rail’s first mark')
}
// The foot line: the rail's account and the sidebar's Upgrade plan, inside the pane's foot.
const account = (await page.evaluate(() => {
const buttons = [...document.querySelectorAll('[data-slot="rail"] button')]
const r = buttons[buttons.length - 1].getBoundingClientRect()
return { cy: r.y + r.height / 2, b: r.y + r.height }
}))!
expect(account.b, 'the account stands inside the pane’s foot').toBeLessThanOrEqual(pane.b)
if (path !== '/beta') {
const upgrade = await must(page, '[data-slot="column-upgrade"]')
near(upgrade.cy, account.cy, 'Upgrade plan on the account’s line')
expect(upgrade.b, 'Upgrade plan stands inside the pane’s foot').toBeLessThanOrEqual(pane.b)
}
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth), 'nothing sideways').toBeLessThanOrEqual(0)
})
}
}
for (const path of ['/chat', '/dev', '/beta']) {
test(`${scheme} 390x844 ${path}: the bar and the drawer stand on one line, and the foot stands on the composer's`, async ({ page }) => {
const size = { width: 390, height: 844 }
await open(page, path, size, scheme)
const gap = await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('[data-slot="frame"]')!).paddingBottom) || 0)
void gap
const token = await page.evaluate(() => {
const probe = document.createElement('div')
probe.style.height = 'var(--pane-gap)'
document.body.append(probe)
const h = probe.getBoundingClientRect().height
probe.remove()
return h
})
// Shut: a gap above the bar's marks, and below the composer.
const menu = await must(page, '[data-slot="bar-menu"]')
const mode = await must(page, '[data-slot="bar-mode"]')
expect(menu.y, 'a gap above the bar’s round buttons').toBeGreaterThanOrEqual(token - ONE)
expect(mode.y, 'a gap above the bar’s pill').toBeGreaterThanOrEqual(token - ONE)
near(menu.cy, mode.cy, 'the bar’s marks on one line')
if (path === '/chat') {
const composer = await must(page, 'main[data-slot="pane"] [data-slot="composer"]')
near(size.height - composer.b, token, 'the gap below the composer')
}
// Open: the drawer's title row on the bar's line, the foot on the composer's.
await page.locator('[data-slot="bar-menu"]').click()
await page.locator('[data-slot="drawer"][data-state="open"]').waitFor()
await page.waitForTimeout(800)
const title = await must(page, '[data-slot="menu"] [data-slot="app-head"]')
near(title.cy, menu.cy, 'the drawer’s title row on the pane’s menu button')
const pill = await must(page, '[data-slot="drawer-chat"]')
const avatar = await page.evaluate(() => {
const r = document.querySelector('[data-slot="drawer-foot"]')!.lastElementChild!.getBoundingClientRect()
return { cy: r.y + r.height / 2, b: r.y + r.height }
})
near(pill.cy, avatar.cy, 'the Chat pill and the account on one line')
if (path === '/chat') {
const composer = await must(page, 'main[data-slot="pane"] [data-slot="composer"]')
near(pill.b, composer.b, 'the drawer’s foot on the composer’s foot')
}
near(size.height - pill.b, token, 'the drawer’s foot leaves the gap below')
// One column for the icons of the drawer's rows.
const xs = await page.evaluate(() => {
const at = (sel: string) => {
const svg = document.querySelector(`[data-slot="menu"] ${sel} svg`)
return svg ? svg.getBoundingClientRect().x : null
}
return ['[data-slot="app-new"]', '[data-slot="place-studio"]', '[data-slot="place-tabs"]', '[data-slot="place-more"]', '[data-slot="column-upgrade"]'].map(at).filter((x) => x !== null) as number[]
})
expect(xs.length, 'the drawer’s rows are drawn').toBeGreaterThanOrEqual(3)
for (const x of xs) near(x, xs[0], `a row’s icon on the rows’ column (${xs.join(', ')})`)
})
}
}