mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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
178 lines
8.5 KiB
TypeScript
178 lines
8.5 KiB
TypeScript
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(', ')})`)
|
||
})
|
||
}
|
||
}
|