mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
142 lines
6.4 KiB
TypeScript
142 lines
6.4 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test'
|
|
import { serveExport } from './export'
|
|
|
|
/**
|
|
* /PLAYGROUND, AT EVERY WIDTH, IN BOTH THEMES.
|
|
*
|
|
* Five things, each read off the rendered page:
|
|
*
|
|
* axis the hero's heading, paragraph and button row share one centre line.
|
|
* badge the badge over the heading carries words a reader can see.
|
|
* contrast every piece of text in `main` clears WCAG AA against the ground it
|
|
* sits on: 4.5:1, or 3:1 at 24px and up (18.66px bold).
|
|
* width nothing scrolls sideways.
|
|
* bottom at the end of the page the composer is the footer's.
|
|
*
|
|
* The ground is the composite of every background between the text and the
|
|
* canvas, alpha included. Colours are read back through a canvas, so an
|
|
* `oklab()` or `color-mix()` value is measured as the sRGB the screen shows.
|
|
*/
|
|
|
|
const WIDTHS = [360, 390, 768, 1024, 1280, 1440, 1920, 2560]
|
|
const THEMES = ['dark', 'light'] as const
|
|
const height = (w: number) => (w < 768 ? 800 : w < 1280 ? 1024 : 900)
|
|
|
|
let site: Awaited<ReturnType<typeof serveExport>>
|
|
test.beforeAll(async () => {
|
|
site = await serveExport()
|
|
})
|
|
test.afterAll(async () => {
|
|
await site.close()
|
|
})
|
|
|
|
async function open(page: Page, theme: (typeof THEMES)[number], width: number) {
|
|
await page.setViewportSize({ width, height: height(width) })
|
|
await page.emulateMedia({ colorScheme: theme })
|
|
await page.addInitScript((t) => localStorage.setItem('hanzo.appearance', JSON.stringify({ theme: t })), theme)
|
|
await page.goto(`${site.url}/playground`)
|
|
await expect(page.locator('main h1')).toBeVisible({ timeout: 20_000 })
|
|
await expect.poll(() => page.evaluate(() => document.documentElement.className)).toContain(theme)
|
|
}
|
|
|
|
/** Every text in `main` below AA, as `ratio text`. Scrolls each band into view so
|
|
* its entrance has run before its ink is read. */
|
|
async function faint(page: Page): Promise<string[]> {
|
|
await page.evaluate(async () => {
|
|
for (let y = 0; y < document.documentElement.scrollHeight; y += innerHeight / 2) {
|
|
scrollTo(0, y)
|
|
await new Promise((r) => setTimeout(r, 60))
|
|
}
|
|
scrollTo(0, 0)
|
|
})
|
|
await page.waitForTimeout(700)
|
|
return page.evaluate(() => {
|
|
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true })!
|
|
const rgba = (css: string): [number, number, number, number] => {
|
|
ctx.clearRect(0, 0, 1, 1)
|
|
ctx.fillStyle = '#000'
|
|
ctx.fillStyle = css
|
|
ctx.fillRect(0, 0, 1, 1)
|
|
const d = ctx.getImageData(0, 0, 1, 1).data
|
|
return [d[0], d[1], d[2], d[3] / 255]
|
|
}
|
|
const over = (top: number[], under: number[]) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3]))
|
|
const lum = (c: number[]) =>
|
|
c
|
|
.map((v) => v / 255)
|
|
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
.reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0)
|
|
const canvas = rgba(getComputedStyle(document.documentElement).backgroundColor)
|
|
const page = canvas[3] > 0 ? canvas.slice(0, 3) : [255, 255, 255]
|
|
const ground = (el: Element) => {
|
|
const layers: number[][] = []
|
|
for (let n: Element | null = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
const c = rgba(getComputedStyle(n).backgroundColor)
|
|
if (c[3] > 0) layers.push(c)
|
|
}
|
|
return layers.reverse().reduce((under, top) => over(top, [...under, 1]), page)
|
|
}
|
|
const out: string[] = []
|
|
for (const el of Array.from(document.querySelectorAll('main *'))) {
|
|
const own = Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent || '').trim())
|
|
if (!own) continue
|
|
const s = getComputedStyle(el)
|
|
const b = el.getBoundingClientRect()
|
|
if (b.width === 0 || b.height === 0 || s.visibility === 'hidden') continue
|
|
const bg = ground(el)
|
|
const ink = over(rgba(s.color), [...bg, 1])
|
|
const [hi, lo] = [lum(ink), lum(bg)].sort((a, z) => z - a)
|
|
const ratio = (hi + 0.05) / (lo + 0.05)
|
|
const size = parseFloat(s.fontSize)
|
|
const large = size >= 24 || (size >= 18.66 && Number(s.fontWeight) >= 700)
|
|
if (ratio < (large ? 3 : 4.5)) out.push(`${ratio.toFixed(2)} ${(el.textContent || '').trim().slice(0, 48)}`)
|
|
}
|
|
return out
|
|
})
|
|
}
|
|
|
|
for (const theme of THEMES) {
|
|
for (const width of WIDTHS) {
|
|
test(`/playground reads right at ${width}px in ${theme}`, async ({ page }) => {
|
|
await open(page, theme, width)
|
|
|
|
const hero = await page.evaluate(() => {
|
|
const h1 = document.querySelector('main h1')!
|
|
const section = h1.closest('section')!
|
|
const mid = (boxes: { getBoundingClientRect(): DOMRect }[]) => {
|
|
const r = boxes.map((e) => e.getBoundingClientRect())
|
|
return (Math.min(...r.map((b) => b.left)) + Math.max(...r.map((b) => b.right))) / 2
|
|
}
|
|
const text = (el: Element) => {
|
|
const range = document.createRange()
|
|
range.selectNodeContents(el)
|
|
return mid([range])
|
|
}
|
|
const badges = Array.from(section.querySelectorAll('[data-slot="chip"]')).map((p) => (p.textContent || '').trim())
|
|
return {
|
|
heading: text(h1),
|
|
paragraph: text(section.querySelector('p[data-slot="lede"]')!),
|
|
buttons: mid(Array.from(section.querySelectorAll('a[data-slot="action"]'))),
|
|
badge: badges.length ? text(section.querySelector('[data-slot="chip"]')!) : null,
|
|
badges,
|
|
axis: section.getBoundingClientRect().left + section.getBoundingClientRect().width / 2,
|
|
}
|
|
})
|
|
for (const [name, x] of Object.entries({ heading: hero.heading, paragraph: hero.paragraph, buttons: hero.buttons, badge: hero.badge })) {
|
|
if (x !== null) expect(Math.abs(x - hero.axis), `the hero's ${name} is off its centre line`).toBeLessThanOrEqual(2)
|
|
}
|
|
expect(hero.badges.length, 'the hero has its badge').toBeGreaterThan(0)
|
|
for (const words of hero.badges) expect(words, 'a badge with no words').not.toBe('')
|
|
|
|
expect(await faint(page), 'text below WCAG AA').toEqual([])
|
|
|
|
const wide = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)
|
|
expect(wide, 'the page scrolls sideways').toBeLessThanOrEqual(0)
|
|
|
|
await page.evaluate(() => scrollTo(0, document.documentElement.scrollHeight))
|
|
const ask = page.locator('footer[data-slot="footer"] [data-slot="prism"]')
|
|
await expect(ask, 'the composer is the footer\'s, where the page ends').toBeVisible({ timeout: 20_000 })
|
|
})
|
|
}
|
|
}
|