mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
A gate that closed the export server with its page still open waited on that page's keep-alive connections: server.close() resolves only once they drop, measured at 10-12 s on /board, which spent thumbrooms', workspace's and the corner's budget and failed them on CI with no step named. close() now drops the connections too. The composer's flash check measured before React owned the page: the export's controls carry no transition until gui's runtime styles land at hydration (about 0.5 s), so it read `none` one run in three. It waits for html[data-hydrated] first; every assertion stands. The support-session gate allows one identify, the operator's own on the link's page before the hop to hanzo.id: that page is the operator's browser signed in as itself, and whether its identify flushes before the page leaves is a race. Once the support session lands nothing is identified, the person or anyone, and the hint still goes nowhere. Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
212 lines
9.4 KiB
TypeScript
212 lines
9.4 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
|
import { serveExport } from './export'
|
|
|
|
/**
|
|
* Focus on the composer is ONE ring, and it arrives.
|
|
*
|
|
* The bar is 576px of perimeter at the bottom of every page, so anything drawn
|
|
* around it is drawn across the fold. It has carried three focus signals at once
|
|
* — an outline at an offset, the spectrum band lifting, and the border
|
|
* brightening — which is not a ring but a flare, and the outline arrived WHITE:
|
|
* `outline-color`'s initial value is `currentColor`, Tailwind's
|
|
* `transition-colors` animates outline-color, so a control wearing that utility
|
|
* interpolates its ring from alpha 1.0 down to its real alpha. Measured on the
|
|
* composer's [+] before the fix: 1.0 in the first frame, 0.72 settled.
|
|
*
|
|
* None of that is visible in a build, a typecheck, or a screenshot taken after
|
|
* it settles. It is visible in computed style, which is what this reads.
|
|
*/
|
|
|
|
/** The floor WCAG 1.4.11 puts under a focus indicator. */
|
|
const FLOOR = 3
|
|
|
|
/** sRGB relative luminance, WCAG 2.x. */
|
|
function luminance([r, g, b]: number[]): number {
|
|
const channel = (value: number) => {
|
|
const s = value / 255
|
|
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
}
|
|
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
|
}
|
|
|
|
function contrast(a: number[], b: number[]): number {
|
|
const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x)
|
|
return (lighter + 0.05) / (darker + 0.05)
|
|
}
|
|
|
|
/** `rgb(a)` as numbers. Alpha defaults to 1, which is what an opaque ground is. */
|
|
function parse(color: string): number[] {
|
|
const values = color.match(/[\d.]+/g)?.map(Number) ?? []
|
|
return [values[0] ?? 0, values[1] ?? 0, values[2] ?? 0, values[3] ?? 1]
|
|
}
|
|
|
|
/** Source-over, the way the compositor does it. */
|
|
function over(top: number[], ground: number[]): number[] {
|
|
const a = top[3]
|
|
return [0, 1, 2].map((i) => top[i] * a + ground[i] * (1 - a))
|
|
}
|
|
|
|
/**
|
|
* Both themes, because the ring is a token and a token has two values. It shipped
|
|
* as 40% white in both, which on the light theme's white pill is no ring at all.
|
|
* `/` carries the footer bar; `/start` the ask with its hairline and the dock.
|
|
*/
|
|
for (const theme of ['dark', 'light'] as const)
|
|
for (const route of ['/', '/start'])
|
|
test(`the composer on ${route} answers focus with one ring in ${theme}, and it can never flash`, async ({ page }) => {
|
|
const site = await serveExport()
|
|
try {
|
|
await page.addInitScript((t) => {
|
|
try {
|
|
localStorage.setItem('hanzo.appearance', JSON.stringify({ theme: t }))
|
|
} catch {}
|
|
}, theme)
|
|
await page.goto(site.url + route)
|
|
await expect.poll(() => page.evaluate(() => document.documentElement.classList.contains('light'))).toBe(theme === 'light')
|
|
await page.waitForSelector("[data-slot='prism'] input")
|
|
// Measured once React owns the page (app/providers `data-hydrated`): the
|
|
// export's controls carry no transition until gui's runtime styles land at
|
|
// hydration (measured: `none` for the first ~0.5 s, then `background-color,
|
|
// color, opacity, outline-color`), and a focus can hold before then, so the
|
|
// flash check below raced hydration and read `none` one run in three.
|
|
await page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
|
|
|
|
const read = () =>
|
|
page.evaluate(() => {
|
|
const pill = document.querySelector("[data-slot='prism']") as HTMLElement
|
|
const panel = pill.querySelector(":scope > :not([data-slot^='prism-'])") as HTMLElement
|
|
const style = getComputedStyle(panel)
|
|
return {
|
|
ring: style.boxShadow,
|
|
outline: style.outlineStyle,
|
|
border: style.borderTopColor,
|
|
band: getComputedStyle(pill.querySelector("[data-slot='prism-ring']") as HTMLElement).opacity,
|
|
page: getComputedStyle(document.body).backgroundColor,
|
|
ground: style.backgroundColor,
|
|
ink: getComputedStyle(pill.querySelector('input') as HTMLElement).color,
|
|
}
|
|
})
|
|
|
|
// AT REST means after the theme's colours have eased in. The pill's border
|
|
// eases to its token once the page hydrates, and read during that ease it
|
|
// is a value on its way (rgba(137…) and rgba(175…) on two CI runs), which
|
|
// the check below then calls a second border. Read until two reads apart
|
|
// agree.
|
|
await page.locator('html[data-hydrated]').waitFor({ state: 'attached', timeout: 30_000 })
|
|
let rest = await read()
|
|
await expect
|
|
.poll(async () => {
|
|
const was = JSON.stringify(rest)
|
|
await page.waitForTimeout(200)
|
|
rest = await read()
|
|
return JSON.stringify(rest) === was
|
|
})
|
|
.toBe(true)
|
|
|
|
// Programmatic focus, never a click: a pointer over the pill is HOVERING,
|
|
// and hover lifts the band on purpose. Mixing the two would measure both and
|
|
// could not tell which one answered.
|
|
//
|
|
// Focusing ONCE is not enough. `load` fires well before React has hydrated
|
|
// the export, and hydration takes the caret with it — so a single `focus()`
|
|
// measures an unfocused pill, which passes half of what is below and means
|
|
// nothing. Ask until it holds, then measure.
|
|
await expect
|
|
.poll(async () => {
|
|
await page.locator("[data-slot='prism'] input").first().focus()
|
|
return page.evaluate(
|
|
() => document.querySelector("[data-slot='prism'] input")?.matches(':focus-visible') ?? false,
|
|
)
|
|
})
|
|
.toBe(true)
|
|
|
|
// It ARRIVES rather than appears, so the first frame after focus is the
|
|
// start of the ease and not the answer — reading once here measures a ring
|
|
// at almost no alpha and fails on a value that is about to be right. Waiting
|
|
// for the requirement is also the assertion: the ring becomes legible
|
|
// against the page outside it, and against the pill's glass inside it.
|
|
const legible = async (against: 'page' | 'ground') => {
|
|
const now = await read()
|
|
const ground = over(parse(now.ground), parse(now.page))
|
|
const ring = over(parse(now.ring), ground)
|
|
return contrast(ring, against === 'page' ? parse(now.page) : ground)
|
|
}
|
|
await expect.poll(() => legible('page')).toBeGreaterThanOrEqual(FLOOR)
|
|
await expect.poll(() => legible('ground')).toBeGreaterThanOrEqual(FLOOR)
|
|
|
|
const focused = await read()
|
|
|
|
// ONE ring. A shadow is the ring; an outline beside it is the second border
|
|
// this whole arrangement exists to remove.
|
|
expect(focused.outline).toBe('none')
|
|
expect(rest.ring).not.toBe('none') // …and it rests at zero alpha, or it cannot ease
|
|
expect(parse(rest.ring)[3]).toBe(0)
|
|
|
|
// Nothing else moves. The band is the pill's own decoration, not a second
|
|
// way of saying "focused", and the border keeps one value.
|
|
expect(focused.band).toBe(rest.band)
|
|
expect(focused.border).toBe(rest.border)
|
|
|
|
// What is typed reads on the pill (WCAG 1.4.3). The ask on /start was a dark
|
|
// pill in both themes, so light-theme ink went down onto near-black.
|
|
const ground = over(parse(focused.ground), parse(focused.page))
|
|
expect(contrast(over(parse(focused.ink), ground), ground), 'typed text against the pill').toBeGreaterThanOrEqual(4.5)
|
|
|
|
// The flash is a TRANSITION, so it is killed at the resting value rather
|
|
// than in the focus rule: a control whose outline-color already IS the ring's
|
|
// has nothing to interpolate from. Read on a control that transitions colour,
|
|
// which is the only kind that could flash.
|
|
const flashable = await page.evaluate(() => {
|
|
const plus = document.querySelector("[data-slot='prism'] button") as HTMLElement
|
|
const style = getComputedStyle(plus)
|
|
return {
|
|
transitions: style.transitionProperty,
|
|
resting: style.outlineColor,
|
|
text: style.color,
|
|
}
|
|
})
|
|
expect(flashable.transitions).toContain('outline-color')
|
|
expect(parse(flashable.resting)[3]).toBeLessThan(1)
|
|
expect(flashable.resting).not.toBe(flashable.text)
|
|
} finally {
|
|
await site.close()
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Every composer control is a circle, measured rather than inferred from the
|
|
* markup: @hanzo/composer's `Control` draws itself, and a control that is not
|
|
* one still renders a button.
|
|
*/
|
|
test('every composer control is a circle, on every composer the page draws', async ({ page }) => {
|
|
const site = await serveExport()
|
|
try {
|
|
await page.goto(site.url)
|
|
await page.waitForSelector("[data-slot='prism-control']")
|
|
|
|
const controls = await page.evaluate(() =>
|
|
[...document.querySelectorAll("[data-slot='prism'] button")].map((el) => {
|
|
const box = el.getBoundingClientRect()
|
|
const radius = parseFloat(getComputedStyle(el).borderRadius) || 0
|
|
return {
|
|
where: el.closest('[data-dock]') ? 'dock' : 'page',
|
|
width: Math.round(box.width),
|
|
height: Math.round(box.height),
|
|
radius,
|
|
composed: el.getAttribute('data-slot') === 'prism-control',
|
|
}
|
|
}),
|
|
)
|
|
|
|
expect(controls.length, 'the page draws at least one composer control').toBeGreaterThan(0)
|
|
for (const control of controls) {
|
|
const at = `${control.where} control ${control.width}x${control.height}`
|
|
expect(control.composed, `${at} is a Control, or nothing shapes it`).toBe(true)
|
|
expect(control.radius, `${at} is round`).toBeGreaterThanOrEqual(Math.min(control.width, control.height) / 2)
|
|
expect(control.width, `${at} is square in its box, so the radius reads as a circle`).toBe(control.height)
|
|
}
|
|
} finally {
|
|
await site.close()
|
|
}
|
|
})
|