Files
zeekay 8524c0cc99 Draw the nav tree as bar, sheet and footer, and restore the Sep 5 fold
The header reads Hanzo AI, Research, Products, Solutions, Developers
and Learn, then search, then Log in and Try Hanzo. The wordmark's
chevron opens the company: Explore Company and Resources. Each menu
wears the shell's mark before its label, and each opens a full-width
plane whose lead column is set in display type. Log in offers Hanzo
App and API Platform; Try Hanzo goes to hanzo.build.

Below 1024px the bar is the name, search and a menu button, and the
sheet is the whole tree, one section open at a time, then Log in and
Try Hanzo. The footer draws the same tree with Terms & Policies last.
All three read components/home/nav-data.ts.

The first screen is the fold hanzo.ai shipped on Sep 5 again: Build
what's next., Download Hanzo and Install CLI, and the Hanzo App drawn
as the payments-api window with the Enso approval behind it. It lands
on the reference's pixels at 1440 and 390. The composer dock waits
until the reader has scrolled past the drawn app.

The shell's HanzoHeader has no company slot, no search in the row and
no log-in menu, so the layout is drawn here from the shell's parts:
its marks, wordmark, palette and account menu.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
2026-09-27 14:42:42 -07:00

189 lines
10 KiB
JavaScript
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.
// The product shot on the landing: the Bots room, captured
// from the shipped site as a signed-in reader sees them, with the org's own
// test agents hidden and its names swapped for a sample company.
//
// node scripts/room-shots.mjs [https://hanzo.ai]
//
// Reads the bearer from /tmp/hz.tok (the same file the e2e specs read) and
// writes a plate per theme — public/shots/bots-1440-{light,dark}.webp —
// because a screenshot is the one thing on the
// page that cannot retune. The landing follows the reader's theme down to the
// window frame these sit in, and a single capture made one of those frames a
// lit rectangle on a dark page or a dark one on a lit page, depending which way
// the capture happened to fall. Two plates, and the frame and its contents
// agree; components/home/Shot.tsx is the half that picks.
//
// A plate is MEASURED before it is kept: mean luminance, light against dark.
// The room reads the scheme through @media, so a context asking for one and
// getting the other is a silent failure — two identical plates under two names
// — and the numbers are what catches it.
import { createRequire } from 'node:module'
import { readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
/** A module from the pnpm store when it is not hoisted. */
function fromStore(name) {
try {
return require(name)
} catch {
const store = path.join(root, 'node_modules/.pnpm')
const dir = readdirSync(store).find((d) => d.startsWith(`${name}@`))
if (!dir) throw new Error(`${name} is not installed`)
return require(path.join(store, dir, 'node_modules', name))
}
}
const { chromium } = fromStore('playwright')
const BASE = process.argv[2] || 'https://hanzo.ai'
/** The session a signed-in room needs, as e2e/credential.ts seeds it. */
const signedIn = ({ t, sidebar }) => {
localStorage.setItem('hanzo:sidebar:open', String(sidebar))
localStorage.setItem('hanzo_iam_access_token', t)
localStorage.setItem('hanzo_iam_id_token', t)
const c = JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
localStorage.setItem('hanzo_iam_expires_at', String(c.exp * 1000))
localStorage.setItem('hanzo:who', c.sub)
const org = c.orgs?.[0]?.org ?? c.owner
if (org) {
localStorage.setItem('hanzo_iam_current_org', org)
localStorage.setItem('hanzo:org:picked', '1')
}
for (const r of ['chat', 'meet', 'calendar', 'contacts', 'inbox', 'drive', 'board', 'bots', 'dev'])
localStorage.setItem(`hanzo:${r}:guide`, 'unneeded')
}
/**
* Sample values over the org's own. Rows naming a test agent are hidden, not
* removed, so React keeps the nodes it owns; text is changed on the text nodes
* for the same reason.
*
* A ROW IS NEVER MOST OF THE ROOM. The climb from the name to the row it sits
* in runs while the parent still holds exactly one test name — which is a fair
* reading of "still inside the row" only while there are others elsewhere on
* the page. With ONE such name in the whole room the condition never turns
* false, the climb walks to <body>, and the capture comes back a blank field.
* It measured as a light page and passed the scheme check; only the eye caught
* it. So the climb stops at a quarter of the room's text, and whatever it
* settled on is checked against that before it is hidden.
*/
const sample = () => {
const junk = /e2e-bot|example-gre|verify-run|should-never/i
const junkG = /e2e-bot|example-gre|verify-run|should-never/gi
const room = (document.body.textContent || '').length
for (const leaf of [...document.querySelectorAll('body *')].filter((e) => e.childElementCount === 0 && junk.test(e.textContent || ''))) {
let el = leaf
while (
el.parentElement &&
el.parentElement !== document.body &&
(el.parentElement.textContent || '').length * 4 < room &&
((el.parentElement.textContent || '').match(junkG) || []).length === 1 &&
!/Browse the directory|Invite teammates/.test(el.parentElement.textContent || '')
)
el = el.parentElement
if ((el.textContent || '').length * 4 >= room) continue
el.style.display = 'none'
}
const runs = ['Morning inbox sweep', 'Invoice reminders', 'Weekly outreach', 'Expense report', 'Support digest', 'Lead research', 'Standup notes']
const said = ['Replied to 14 threads and flagged 2 for you.', 'Sent 6 reminders; 4 invoices paid.', 'Drafted 20 first touches for review.', 'Filed 31 receipts against the Q3 budget.', 'Summarised 9 tickets; 1 needs a human.', 'Found 12 leads matching the brief.', 'Posted the standup summary to Board.']
const threads = ['Launch plan with Des', 'Pricing page with Mark', 'Q3 forecast with Zach', 'Onboarding flow with Vi', 'Support macros with Su', 'Hiring loop with Maya', 'Roadmap review']
let r = 0, w = 0, th = 0
for (const el of [...document.querySelectorAll('body *')].filter((e) => e.childElementCount === 0 && e.textContent.trim())) {
const t = el.textContent.trim()
let out = null
if (/^\{"/.test(t)) out = said[w++ % said.length]
else if (/^(auth token|kms secrets|kms health|ci|playground|edge|whoami)$/.test(t)) out = runs[r++ % runs.length]
else if (/^With Antje( and des)?$/.test(t)) out = threads[th++ % threads.length]
else if (t === 'error') out = 'done'
else if (/^error · \d+ turns$/.test(t)) out = t.replace('error', 'done')
else if (/^Enterprise · \$[\d.]+ left$/.test(t)) out = 'Team'
else if (t === '10' && /Agents/.test(el.previousElementSibling?.textContent || '')) out = '4'
else if (/\bhanzo\b/.test(t) && el.tagName !== 'OPTION') out = t.replace(/\bhanzo\b/g, 'acme')
if (out !== null && out !== t) {
const kids = [...el.childNodes].filter((k) => k.nodeType === 3)
if (kids.length) {
kids[0].nodeValue = out
for (const k of kids.slice(1)) k.nodeValue = ''
}
}
}
for (const o of document.querySelectorAll('option')) if (o.textContent.trim() === 'hanzo') o.textContent = 'acme'
}
const SHOTS = [
{ room: 'bots', width: 1440, height: 900, sidebar: true, ready: 'text=Dev — the builder', open: 'Dev — the builder', out: 'bots-1440', wide: 2304 },
]
/**
* What a plate looks like as two numbers: how light it is, and how much is
* going on in it. Greyscale, shrunk past the detail, then the mean and the
* spread about it — small enough that one bright panel cannot carry the mean,
* and a plate with nothing drawn on it has a spread of zero.
*/
async function read(file) {
const { data } = await sharp(file).greyscale().resize(32, 32, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true })
const mean = data.reduce((sum, v) => sum + v, 0) / data.length
const spread = Math.sqrt(data.reduce((sum, v) => sum + (v - mean) ** 2, 0) / data.length)
return { mean, spread }
}
/** A light plate is light, a dark one is dark, and both are pictures of a room. */
function judge(name, lit, dim) {
const fault = []
if (lit.mean < 160) fault.push(`light is ${lit.mean.toFixed(1)}, wanted 160+`)
if (dim.mean > 96) fault.push(`dark is ${dim.mean.toFixed(1)}, wanted 96 or under`)
if (lit.mean - dim.mean < 64) fault.push(`the plates are ${(lit.mean - dim.mean).toFixed(1)} apart — the room ignored the scheme`)
// A FLAT PLATE IS NOT A ROOM. The sample pass hides rows, and a run where it
// climbed too far hid the page: what came back was an even field, which reads
// as a perfectly light plate and passed every test above it.
for (const [ground, plate] of [['light', lit], ['dark', dim]])
if (plate.spread < 4) fault.push(`the ${ground} plate is flat (spread ${plate.spread.toFixed(1)}) — nothing drew`)
if (fault.length) throw new Error(`${name}: ${fault.join('; ')}`)
}
const tok = readFileSync('/tmp/hz.tok', 'utf8').trim()
const sharp = fromStore('sharp')
const browser = await chromium.launch()
for (const s of SHOTS) {
const lux = {}
for (const scheme of ['light', 'dark']) {
// `colorScheme` is the whole of it: the room answers `prefers-color-scheme`
// like any reader's browser, so the same page photographed twice is the
// same room in the reader's two grounds rather than two crops.
const ctx = await browser.newContext({ viewport: { width: s.width, height: s.height }, deviceScaleFactor: 2, colorScheme: scheme })
await ctx.addInitScript(signedIn, { t: tok, sidebar: s.sidebar })
const page = await ctx.newPage()
// The room reads its org before it is a picture of anything. `ready` is the
// first thing that only the read can draw; a slow read gets three tries.
for (let attempt = 1; ; attempt++) {
await page.goto(`${BASE}/${s.room}`, { waitUntil: 'load', timeout: 60_000 })
const drawn = await page.locator(s.ready).first().waitFor({ state: 'visible', timeout: 45_000 }).then(() => true, () => false)
if (drawn) break
if (attempt === 3) throw new Error(`${s.room}: the room never drew ${s.ready}`)
}
await page.waitForTimeout(2500)
if (s.open) {
await page.getByText(s.open).first().click().catch(() => {})
await page.waitForTimeout(2500)
}
await page.evaluate(sample)
// The sample pass hides things, so what it left is checked before the
// shutter: the room has to still be drawing the thing that said it was
// ready. This is the direct form of the flatness test below, and it names
// the cause rather than the symptom.
if (!(await page.locator(s.ready).first().isVisible()))
throw new Error(`${s.room}: the sample pass hid the room — ${s.ready} is gone`)
const png = await page.screenshot()
const file = path.join(root, 'public/shots', `${s.out}-${scheme}.webp`)
await sharp(png).resize({ width: s.wide }).webp({ quality: 82 }).toFile(file)
lux[scheme] = await read(file)
console.log(`${s.out}-${scheme}.webp ← ${BASE}/${s.room} at ${s.width}×${s.height} · luminance ${lux[scheme].mean.toFixed(1)} · spread ${lux[scheme].spread.toFixed(1)}`)
await ctx.close()
}
judge(s.out, lux.light, lux.dark)
}
await browser.close()