Files
hanzo.ai/e2e/gates/shell-gaps.spec.ts
zeekay d93ff810c5 deps: @hanzo/ui 8.29.22, @hanzo/app 0.2.42, @hanzo/event 0.3.63
The git section's Heatmap, Sparkline and Repo come from @hanzo/ui/marketing;
the phone menu draws the whole rail, so shell-gaps reads the rail's marks and
account where the Chat pill was, and sidebar.spec reads /referral as the beta's
own link and board.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 09:57:15 -07:00

185 lines
9.3 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 rail's account stands on the composer's foot`, 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')
// The rail stands down the menu's start edge: its first mark on the title row, its account on the foot line.
const first = await must(page, '[data-slot="menu"] [data-slot="rail-chat"]')
near(first.cy, title.cy, 'the rail’s first mark on the drawer’s title row')
expect(await page.locator('[data-slot="drawer-chat"]').count(), 'New chat is the bar’s: the drawer has no pill of its own').toBe(0)
const account = await page.evaluate(() => {
const buttons = [...document.querySelectorAll('[data-slot="menu"] [data-slot="drawer-rail"] button')]
const r = buttons[buttons.length - 1].getBoundingClientRect()
return { b: r.y + r.height }
})
expect(size.height - account.b, 'the rail’s account leaves the gap below').toBeGreaterThanOrEqual(token - ONE)
if (path === '/chat') {
const composer = await must(page, 'main[data-slot="pane"] [data-slot="composer"]')
expect(Math.abs(account.b - composer.b), 'the rail’s account on the composer’s foot').toBeLessThanOrEqual(8)
}
// 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="column-upgrade"]'].map(at).filter((x) => x !== null) as number[]
})
expect(xs.length, 'the drawer’s rows are drawn').toBeGreaterThanOrEqual(path === '/chat' ? 2 : 1)
expect(await page.locator('[data-slot="menu"] [data-slot^="place-"]').count(), 'the places are the rail’s marks, not rows').toBe(0)
for (const mark of ['rail-chat', 'rail-dev', 'rail-studio', 'rail-tabs']) {
expect(await page.locator(`[data-slot="menu"] [data-slot="${mark}"]`).count(), `${mark} is a mark down the drawer’s start edge`).toBe(1)
}
for (const x of xs) near(x, xs[0], `a row’s icon on the rows’ column (${xs.join(', ')})`)
})
}
}