Files
hanzo.ai/e2e/gates/steel.spec.ts
zeekay 44626a40c3 chrome: the bar is glass on every page, and the beta strip is blade steel
The bar stood in the page's flow, clear at rest, so a first screen whose
ground is a picture or a light started below it, and the bar read as a
black band over the html's #000 ground: above the creative models'
paintings, the kit pages' glow and the lit product heroes. It is glass now
at rest and scrolled ($background/72 under the chrome blur; the scroll
listener is gone), and such a first screen spreads `under(pt)` from
components/shell/dock.ts: up by the bar's height, the same back on its top
padding, so its ground starts at the top of the window behind the glass and
no word moves. On Splash (the six creative models and Zen), PageHero, /edge,
/identity, /functions, /playground, /operative, /realtime and /enso. On
/beta the site's bar at rest keeps the glass and paints the focused pages'
fixed glow on it (components/shell/focus.tsx), so the light runs on
unbroken. 72% is kept: over Zen's painting the nav's ink holds 5.1:1 at .72
and falls to 3.7:1 at .6.

The beta strip is polished curved steel in CSS gradients: a faint top line,
a ridge 5px down, the flat dark under the words, a temper line and the
bright 1px edge at the bottom; one `/` light at 70 degrees, resting
mid-strip, moved by transform, brighter where it crosses the ridge and the
edge than under the words. The 1px/3px grain that read as stripes is gone,
and so is the root script that set --beta-tilt on <html> every pointer
frame on every page, restyling the whole document and ignoring reduced
motion. White words hold 5.4:1 against the brightest steel under them,
where the live strip measured 4.35:1 at 1440 and 3.45:1 on a phone.

Gates: e2e/gates/glass.spec.ts and e2e/gates/steel.spec.ts. The composer
gate reads its rest once the theme's colours have eased in: read straight
after the input appeared, the pill's border was a value on its way to its
token (rgba(137…), rgba(175…) on two CI runs) and failed the check that the
border keeps one value through focus.

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
2026-10-22 12:02:12 -07:00

152 lines
7.0 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 { test, expect, type Browser, type Page } from '@playwright/test'
import { serveExport } from './export'
/**
* THE BETA STRIP IS POLISHED STEEL WITH ONE LIGHT ON IT, AND ITS WORDS READ.
*
* The strip under the bar on the landing (`[data-slot="beta"]`,
* components/home/beta.tsx; its look is CSS in app/layout.tsx) once carried a
* 1px/3px repeating grain that read as stripes on a fine screen, its bright line
* along the TOP, and a glint leaning `\`. Measured on the shipped page:
*
* - no repeating gradient anywhere in it, so nothing to stripe;
* - the bright line is the bottom edge, the blade's: the last row of pixels is
* the brightest row of the strip, and the first is dim;
* - one light, leaning `/`, steeper than the old 62° from level, at rest in
* the middle, following the pointer and still under reduced motion;
* - white words at 4.5:1 or better against the brightest pixel of steel under
* them, with the light anywhere along the strip, on a laptop and a phone.
*/
let site: Awaited<ReturnType<typeof serveExport>>
test.beforeAll(async () => {
site = await serveExport()
})
test.afterAll(async () => {
await site.close()
})
const STRIP = '[data-slot="beta"]'
async function open(page: Page, width: number, height: number, scheme: 'dark' | 'light' = 'dark') {
await page.route('https://api.hanzo.ai/**', (route) => route.abort())
await page.emulateMedia({ colorScheme: scheme })
await page.setViewportSize({ width, height })
await page.goto(`${site.url}/`)
await expect(page.locator(STRIP)).toBeVisible({ timeout: 20_000 })
await page.locator('html[data-hydrated]').waitFor({ state: 'attached', timeout: 30_000 })
}
/** Relative luminance of every pixel of a PNG, read in a blank page's canvas. */
async function pixels(browser: Browser, png: Buffer): Promise<{ width: number; height: number; lum: number[] }> {
const lab = await browser.newPage()
try {
return await lab.evaluate(async (b64) => {
const img = new Image()
img.src = `data:image/png;base64,${b64}`
await img.decode()
const c = document.createElement('canvas')
c.width = img.width
c.height = img.height
const g = c.getContext('2d')!
g.drawImage(img, 0, 0)
const data = g.getImageData(0, 0, c.width, c.height).data
const L = (v: number) => {
v /= 255
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
const lum: number[] = []
for (let i = 0; i < data.length; i += 4) lum.push(0.2126 * L(data[i]) + 0.7152 * L(data[i + 1]) + 0.0722 * L(data[i + 2]))
return { width: c.width, height: c.height, lum }
}, png.toString('base64'))
} finally {
await lab.close()
}
}
/** White against a ground of luminance `l`. */
const contrast = (l: number) => 1.05 / (l + 0.05)
test('nothing in the strip repeats, so nothing stripes, and its one light leans / and steeper than 62°', async ({ page }) => {
await open(page, 1440, 900)
const looks = await page.locator(STRIP).evaluate((el) => ({
self: getComputedStyle(el).backgroundImage,
before: getComputedStyle(el, '::before').backgroundImage,
after: getComputedStyle(el, '::after').backgroundImage,
}))
for (const [part, image] of Object.entries(looks)) expect(image, `the strip's ${part} draws no repeating gradient`).not.toContain('repeating-')
// A CSS gradient at θ draws its lines along θ − 90°: θ in (90°, 180°) leans
// `/`, and the line stands 180° − θ from level. Steeper than 62° is θ < 118°.
const angle = Number(/linear-gradient\((-?[\d.]+)deg/.exec(looks.after)?.[1])
expect(angle, `the light is one diagonal gradient: ${looks.after.slice(0, 60)}`).toBeGreaterThan(90)
expect(angle).toBeLessThan(118)
})
test('the bright line is the bottom edge, the blade’s, and the top is dim', async ({ page, browser }) => {
await open(page, 1440, 900)
await page.addStyleTag({ content: `${STRIP}>*{visibility:hidden !important}` })
const box = (await page.locator(STRIP).boundingBox())!
const shot = await pixels(browser, await page.screenshot({ clip: box, scale: 'css' }))
const rows = Array.from({ length: shot.height }, (_, y) => {
let sum = 0
for (let x = 0; x < shot.width; x++) sum += shot.lum[y * shot.width + x]
return sum / shot.width
})
const last = rows[rows.length - 1]
expect(last, 'the bottom row is the brightest row of the strip').toBe(Math.max(...rows))
expect(last, 'and it is bright').toBeGreaterThan(0.45)
expect(rows[0], 'the top row is dim').toBeLessThan(0.08)
})
for (const [width, height, scheme] of [
[1440, 900, 'dark'],
[390, 844, 'dark'],
[1440, 900, 'light'],
] as const) {
test(`the words read at 4.5:1 against the brightest steel under them, the light anywhere, ${scheme}, ${width}`, async ({ page, browser }) => {
await open(page, width, height, scheme)
const strip = page.locator(STRIP)
const box = (await strip.boundingBox())!
// Every word and mark, as boxes inside the strip.
const marks = await strip.evaluate((el) => {
const s = el.getBoundingClientRect()
return [...el.children].map((c) => {
const r = c.getBoundingClientRect()
return { x: r.x - s.x, y: r.y - s.y, w: r.width, h: r.height }
})
})
expect(marks.length).toBe(3)
await page.addStyleTag({ content: `${STRIP}>*{visibility:hidden !important}` })
for (const tilt of [0, 0.25, 0.5, 0.75, 1]) {
await strip.evaluate((el, t) => (el as HTMLElement).style.setProperty('--tilt', String(t)), tilt)
const shot = await pixels(browser, await page.screenshot({ clip: box, scale: 'css' }))
for (const m of marks) {
let max = 0
for (let y = Math.max(0, Math.floor(m.y)); y < Math.min(shot.height, Math.ceil(m.y + m.h)); y++)
for (let x = Math.max(0, Math.floor(m.x)); x < Math.min(shot.width, Math.ceil(m.x + m.w)); x++) max = Math.max(max, shot.lum[y * shot.width + x])
expect(contrast(max), `at tilt ${tilt}, white over the brightest steel under a word reads 4.5:1`).toBeGreaterThanOrEqual(4.5)
}
}
})
}
test('the light rests in the middle, follows the pointer, and stays put under reduced motion', async ({ page }) => {
await open(page, 1440, 900)
const shift = () => page.locator(STRIP).evaluate((el) => new DOMMatrix(getComputedStyle(el, '::after').transform).m41)
expect(await shift(), 'at rest the light is in the middle').toBe(0)
// The strip listens from an effect, which can attach after `data-hydrated` on
// a starved runner, so the pointer moves again until the light answers.
await expect(async () => {
await page.mouse.move(1400, 450)
await page.mouse.move(1430, 452)
expect(await shift()).toBeGreaterThan(100)
}, 'the light follows the pointer to the right').toPass({ timeout: 20_000 })
await page.emulateMedia({ reducedMotion: 'reduce' })
await open(page, 1440, 900)
await page.mouse.move(1430, 450)
await page.mouse.move(10, 450)
await page.waitForTimeout(300)
expect(await shift(), 'under reduced motion the light stays in the middle').toBe(0)
})