Files
zeekay a9cb4e5c51 models: the bento and every model page play the model's film
components/family/Film is the one player: the still is the first paint,
the loop is made only after hydration and only when motion is wanted, and
one IntersectionObserver for every film on the page loads a loop just
before it shows, plays it on screen and pauses it off. It fades in on
`playing`, so the step from still to loop cannot be seen.

The bento draws each card's film behind its words with no filter (the art
is grey already), a lighter scrim, the lean and a small grow on hover; the
line keeps to the left of the card and Enso runs its square master below
1024px. /enso, /kai, /zen and the six creative models open on Fold with the
film where a page puts its preview, screened onto the dot field and drawn
inverted in the light theme; Splash is that Fold with a model's words. The
route Enso takes and the decision Kai forms move to the band under it.

e2e/gates/films.spec.ts holds the files, their weight, the loops playing
and pausing, the phone's square Enso, reduced motion and each page's one
heading beside its film.
2026-10-30 07:49:49 -07:00

105 lines
5.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 Page } from '@playwright/test'
import { serveExport } from './export'
/**
* THE BAR IS GLASS ON EVERY PAGE, AND A FIRST SCREEN'S LIGHT RUNS BEHIND IT.
*
* The bar (components/home/shell.tsx) stood in the page's flow, clear at rest,
* over nothing but the page's black ground, so above the creative pages'
* paintings and the kit heroes' glow it read as a black band. It is glass now,
* at rest and scrolled, in both schemes: a translucent fill over a blur. A first screen whose ground is a picture or a
* glow starts at the top of the window, behind it (`under`,
* components/shell/dock.ts), with its heading still clear of the bar. Under an
* open menu the bar is the plane's opaque ground, on purpose: a filtered bar is
* a backdrop root, and the veil would blur the bar instead of the page.
*/
let site: Awaited<ReturnType<typeof serveExport>>
test.beforeAll(async () => {
site = await serveExport()
})
test.afterAll(async () => {
await site.close()
})
const BAR = 'header[data-slot="masthead"]'
/** The alpha of a computed colour: `rgba(…, a)`, `color(srgb … / a)`, or 1 when none is written. */
const alpha = (color: string) => {
const slash = /\/\s*([\d.]+)\s*\)$/.exec(color)
if (slash) return Number(slash[1])
const rgba = /^rgba\([^)]*,\s*([\d.]+)\)$/.exec(color)
return rgba ? Number(rgba[1]) : 1
}
const ground = (page: Page) =>
page.locator(BAR).evaluate((el) => {
const c = getComputedStyle(el)
return { fill: c.backgroundColor, filter: c.backdropFilter || c.getPropertyValue('-webkit-backdrop-filter') }
})
async function glass(page: Page, where: string) {
const { fill, filter } = await ground(page)
expect(alpha(fill), `${where}: the bar's fill is translucent (${fill})`).toBeGreaterThan(0)
expect(alpha(fill), `${where}: the bar's fill is translucent (${fill})`).toBeLessThan(1)
expect(filter, `${where}: the bar frosts what is under it`).toContain('blur(')
}
async function open(page: Page, path: string, scheme: 'dark' | 'light', width = 1440) {
await page.route('https://api.hanzo.ai/**', (route) => route.abort())
await page.emulateMedia({ colorScheme: scheme })
await page.setViewportSize({ width, height: 900 })
await page.goto(`${site.url}${path}`)
await expect(page.locator(BAR)).toBeVisible({ timeout: 20_000 })
await expect.poll(() => page.evaluate(() => document.documentElement.className)).toContain(`t_${scheme}`)
}
for (const scheme of ['dark', 'light'] as const) {
test(`the bar is glass at rest and scrolled on every kind of page, ${scheme}`, async ({ page }) => {
// The landing, a plain page, Studio, a creative model, Zen, a kit page, a
// product page on a lit hero, and Enso's dot field.
for (const path of ['/', '/pricing', '/studio', '/koma', '/zen', '/customers', '/edge', '/enso']) {
await open(page, path, scheme)
await glass(page, `${path} at rest`)
await page.evaluate(() => window.scrollTo(0, 900))
await glass(page, `${path} scrolled`)
}
})
}
for (const width of [1440, 390]) {
test(`a first screen's picture or glow starts behind the bar, and its heading stays clear of it, ${width}`, async ({ page }) => {
// The six creative models and Zen open on their film beside the dot field;
// the kit pages on a glow; six product pages on a lit ground; Enso on its dot field.
for (const path of ['/koma', '/iro', '/mori', '/oto', '/koe', '/uta', '/zen', '/customers', '/research', '/trust', '/edge', '/identity', '/functions', '/playground', '/operative', '/realtime', '/enso']) {
await open(page, path, 'dark', width)
const at = await page.evaluate((bar) => {
const first = document.querySelector('main section')!
const h1 = first.querySelector('h1')!
return {
top: first.getBoundingClientRect().top,
bar: document.querySelector(bar)!.getBoundingClientRect().bottom,
heading: h1.getBoundingClientRect().top,
}
}, BAR)
expect(at.top, `${path}: the first screen starts at the top of the window, behind the bar`).toBe(0)
expect(at.heading, `${path}: its heading stands below the bar`).toBeGreaterThan(at.bar)
}
})
}
test('an open menu stands the bar on the plane’s opaque ground, with no blur', async ({ page }) => {
await open(page, '/pricing', 'dark')
await page.locator('html[data-hydrated]').waitFor({ state: 'attached', timeout: 30_000 })
// The bar's first menu: Research, since the navigation the owner restored on
// 2026-10-26 (LLM.md "Navigation restored"), which has no Platform menu.
const trigger = page.locator(BAR).getByRole('button', { name: 'Research', exact: true })
await expect(async () => {
await trigger.hover()
await expect(page.locator('#menu-research')).toBeVisible({ timeout: 1000 })
}).toPass({ timeout: 15_000 })
await expect.poll(async () => alpha((await ground(page)).fill)).toBe(1)
// The filter fades out with the ground, so it is read at the end of that fade.
await expect.poll(async () => (await ground(page)).filter).toBe('none')
})