mirror of
https://github.com/hanzo-apps/industries.git
synced 2026-10-30 14:47:50 +00:00
The site rendered in the system fallback: @hanzo/design 0.5.x replaced Geist with Zen, and this app pinned 0.4.6, so the family every surface asked for was one nothing declared any more. Bumping design alone strands it — @hanzo/ui inlines a copy of that token block — so the pair moves together, to @hanzo/design 0.5.21 and @hanzo/ui 8.3.4. @hanzo/ui depends on none of its @hanzogui/* peers; it declares them, so this app installs them. With only @hanzo/gui declared, pnpm resolved @hanzogui/lucide-icons-2 a generation behind, which dragged a second @hanzogui/core, and every @hanzo/ui component carrying an icon then read a theme context no provider fills — prerender died on "Missing theme.". Declaring the cohort, as hanzo.ai and the console already do, collapses it to one core. The old font test matched /geist|system-ui|sans-serif/, so it passed on the fallback: the exact failure it existed to catch. Both checks ask the font loader now. shots.mjs asks for Zen Mono only where a page renders mono, because a face loads when a glyph wants it; mutation-checked by aborting **/*.woff2. CommandPalette was controlled, nothing ever owned it, and its global handler could only close — so Cmd+K opened nothing and its test had never passed. A palette is the thing Cmd+K opens, so it owns the shortcut and mounts itself. 51 pages, 414/414 gui classes covered, 36/36 e2e, 60/60 routes across phone+desktop x light+dark. Co-authored-by: Hanzo Dev <[email protected]>
170 lines
7.7 KiB
JavaScript
170 lines
7.7 KiB
JavaScript
/* eslint-disable */
|
|
/**
|
|
* The visual check for a served build — what proves a styling change actually
|
|
* landed. Both failure modes in this stack are SILENT: @hanzo/gui ignores a prop
|
|
* it does not recognise, and an undefined CSS class or `var()` does nothing.
|
|
* Neither errors, neither fails typecheck. Only pixels settle it.
|
|
*
|
|
* It renders real routes in a real browser across the full matrix — phone and
|
|
* desktop, light and dark — and fails on what a CSS migration actually breaks:
|
|
* horizontal overflow, a page that renders no text, a theme that did not apply,
|
|
* and same-origin errors.
|
|
*
|
|
* BOTH THEMES is not thoroughness, it is the minimum. The partner wall passed
|
|
* this check while six of its eight marks were invisible on dark, because the
|
|
* check only ever looked at light.
|
|
*
|
|
* pnpm build && npx serve out -l 3399
|
|
* node scripts/shots.mjs [outDir]
|
|
*/
|
|
import { chromium } from '@playwright/test'
|
|
import { mkdirSync } from 'node:fs'
|
|
|
|
const BASE = process.env.SHOTS_BASE || 'http://localhost:3399'
|
|
const ORIGIN = new URL(BASE).origin
|
|
|
|
/** `missing` routes are EXPECTED to 404 — a 404 page that 200s is the bug, and
|
|
* one that renders three lines of text is doing its job, not failing. */
|
|
const ROUTES = [
|
|
{ path: '/' },
|
|
{ path: '/about' },
|
|
{ path: '/team' },
|
|
{ path: '/pricing' },
|
|
{ path: '/careers' },
|
|
{ path: '/contact' },
|
|
{ path: '/help' },
|
|
{ path: '/models' },
|
|
{ path: '/research' },
|
|
{ path: '/docs' },
|
|
{ path: '/open-source' },
|
|
{ path: '/press' },
|
|
{ path: '/status' },
|
|
{ path: '/products/zen' },
|
|
{ path: '/nope-404', missing: true },
|
|
]
|
|
|
|
/* The page surface each theme must actually paint. If a theme's tokens stop
|
|
resolving these are what change, so they are asserted rather than printed. */
|
|
const SURFACE = { light: 'rgb(255, 255, 255)', dark: 'rgb(0, 0, 0)' }
|
|
|
|
const OUT = process.argv[2] || 'shots'
|
|
mkdirSync(OUT, { recursive: true })
|
|
|
|
const browser = await chromium.launch()
|
|
let bad = 0
|
|
|
|
for (const theme of ['light', 'dark']) {
|
|
for (const width of [390, 1280]) {
|
|
const ctx = await browser.newContext({ viewport: { width, height: 900 } })
|
|
await ctx.addInitScript((t) => localStorage.setItem('theme', t), theme)
|
|
|
|
for (const { path, missing } of ROUTES) {
|
|
const page = await ctx.newPage()
|
|
const errors = []
|
|
/* A request to a third-party host cannot succeed from a local origin, and
|
|
the console noise that follows is the environment, not the page. It is
|
|
reported, never fatal — the page's own failure path is what is on test
|
|
here, and the page passing means that path worked. */
|
|
const offsite = []
|
|
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()))
|
|
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
|
|
page.on('requestfailed', (r) => {
|
|
const where = new URL(r.url()).origin === ORIGIN ? errors : offsite
|
|
where.push('requestfailed: ' + r.url())
|
|
})
|
|
|
|
const res = await page
|
|
.goto(BASE + path, { waitUntil: 'networkidle' })
|
|
.catch((e) => void errors.push('nav: ' + e.message))
|
|
await page.waitForTimeout(400)
|
|
|
|
const m = await page.evaluate(() => {
|
|
const de = document.documentElement
|
|
const cs = getComputedStyle(document.body)
|
|
/* The tap target, not the painted box. @hanzo/ui expands a control's hit
|
|
area with an `::after` overlay driven from `data-touch-y`, so a 32px
|
|
`size="sm"` button is a legal 44px target and counting its box would
|
|
report a problem that is not there. */
|
|
const small = [...document.querySelectorAll('a,button,summary,[role=button]')]
|
|
.filter((el) => el.getClientRects().length)
|
|
.filter((el) => {
|
|
const r = el.getBoundingClientRect()
|
|
const pad = parseFloat(el.dataset.touchY || '0')
|
|
return r.height > 0 && r.height + 2 * pad < 44
|
|
})
|
|
/* A button whose box is shorter than the frame @hanzo/ui asks for is a
|
|
button clipping its own label — the failure a pinned `height` used to
|
|
cause, and the reason 8.0.46 moved to `height:auto` + `minHeight`. */
|
|
const short = [...document.querySelectorAll('[data-slot="button"]')]
|
|
.filter((el) => el.getClientRects().length)
|
|
.filter((el) => {
|
|
const min = parseFloat(getComputedStyle(el).minHeight) || 0
|
|
return el.getBoundingClientRect().height + 0.5 < min
|
|
})
|
|
return {
|
|
overflow: de.scrollWidth - de.clientWidth,
|
|
bg: cs.backgroundColor,
|
|
font: cs.fontFamily.split(',')[0],
|
|
/* THE face, not the name of one. The stack falls back to
|
|
`ui-sans-serif, system-ui` silently — same computed value shape,
|
|
same green build — if the host stops declaring @font-face, so ask
|
|
the font loader whether it actually has the file. */
|
|
zen: document.fonts.check('16px Zen'),
|
|
/* A face loads when a glyph asks for it, so `check` is false on a page
|
|
that renders no mono at all — true of most of this site. Ask both
|
|
questions: does anything here render mono, and did that face arrive. */
|
|
mono: [...document.querySelectorAll('*')].some(
|
|
(el) => getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim() === 'Zen Mono'
|
|
),
|
|
zenMono: document.fonts.check('16px "Zen Mono"'),
|
|
text: (document.body.innerText || '').trim().length,
|
|
h1: document.querySelector('h1')?.innerText?.slice(0, 48) ?? null,
|
|
smallTaps: small.length,
|
|
shortButtons: short.length,
|
|
/* every one-ink mark must resolve to a filter, in every theme */
|
|
inkless: [...document.querySelectorAll('img[alt$="logo"]')].filter(
|
|
(e) => !e.className.includes('hz-ink-')
|
|
).length,
|
|
}
|
|
})
|
|
|
|
const name = `${theme}-${width}${path.replace(/\//g, '_') || '_root'}.png`
|
|
await page.screenshot({ path: `${OUT}/${name}` })
|
|
|
|
const status = res?.status() ?? 0
|
|
const problems = []
|
|
if (m.overflow > 0) problems.push(`overflow ${m.overflow}px`)
|
|
if (!missing && m.text <= 200) problems.push(`no text (${m.text})`)
|
|
if (missing && status !== 404) problems.push(`expected 404, got ${status}`)
|
|
if (!missing && status !== 200) problems.push(`status ${status}`)
|
|
if (m.bg !== SURFACE[theme]) problems.push(`${theme} surface is ${m.bg}`)
|
|
if (m.inkless) problems.push(`${m.inkless} logo(s) with no hz-ink-*`)
|
|
if (!m.zen) problems.push('Zen did not load — the page is on the fallback face')
|
|
if (m.mono && !m.zenMono) problems.push('mono text is on the fallback face')
|
|
if (m.shortButtons) problems.push(`${m.shortButtons} button(s) shorter than their own minHeight`)
|
|
// Console noise is only the page's own once the offsite hosts are excluded
|
|
// — and a `missing` route is SUPPOSED to have served a 404, so the console
|
|
// line the browser logs about it is the expectation being met, not a fault.
|
|
const own = missing
|
|
? errors.filter((e) => !/status of 404/.test(e))
|
|
: errors
|
|
if (!offsite.length && own.length) problems.push(own.slice(0, 2).join(' | '))
|
|
|
|
if (problems.length) bad++
|
|
console.log(
|
|
`${problems.length ? 'BAD ' : 'ok '} ${theme.padEnd(5)} ${String(width).padEnd(5)} ${path.padEnd(
|
|
22
|
|
)} ovf=${m.overflow} text=${m.text} taps<44=${m.smallTaps} bg=${m.bg} font=${m.font} zen=${m.zen}${m.mono ? `/mono=${m.zenMono}` : ''}` +
|
|
(offsite.length ? ` offsite=${offsite.length}` : '') +
|
|
(problems.length ? `\n ${problems.join('\n ')}` : '')
|
|
)
|
|
await page.close()
|
|
}
|
|
await ctx.close()
|
|
}
|
|
}
|
|
|
|
await browser.close()
|
|
console.log(bad ? `FAILURES: ${bad}` : 'ALL GREEN')
|
|
process.exit(bad ? 1 : 0)
|