mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
Eleven new slides walk the platform. P0 is the twelve-call tour the hero already tells — real operations, in the order somebody calls them, each with the verb, the path and the document's own sentence. P1 through P10 are one product from each of the ten layers slide 7 promises: what it is, its base path, the operations under it, its page and its repository. A page declares ONE thing, data-family on its section, and deck/sync.mjs resolves the rest out of lib/data/catalog.json and lib/data/families.json — the two snapshots scripts/sync-catalog.mjs takes of api.hanzo.ai/v1/openapi.json and the commerce catalog, behind its own ratchets. The deck fetches nothing itself: each of those files already has an owner that probes for it, and a second probe here is how the deck and the site come to state different numbers. A family the document stops carrying stops the sync rather than shipping a stale slide. The same script now stamps every data-start from the slide's position, so inserting one renumbers the rest. A6 draws the whole surface: one squarified rectangle per family, its area in the ratio of the operations under it, 183 of them and 2,441 operations. Every product page redraws that map with its own family lit — one instrument seen eleven times rather than eleven loose cards, and a five-operation family is findable in it. The graphics pass on the twelve. The price now rides the score row as a second bar on a log axis, so "the best score at a fraction of the price" is a shape and not an adjective — long white on top, short white underneath. The ten layers carry the products the catalog sells in each, drawn: a span is inline, so the fill painted nothing at all until it was told to be a block. The decades between what a call costs to serve and what the category charges for it are an axis, with the multiple as the distance across it. The dormant agent sits INSIDE the megabyte, in its corner, because that is what the comparison is. The cold-start bars move to the cold-start sheet, which until now was a headline and one row. A4 draws the distance left to close off its own table. A label is what precedes a colon. `.*?` matched the empty string ahead of an optional one, so every A1-A3 detail row fell back to the claim and the sheet printed "A dormant agent" three times. deck/shoot.mjs is the verification the contract already described, made reproducible. It seeks each slide to its own still frame before measuring — the paused timeline holds every part it has not reached 22px down, and measuring through that reports a phantom 22 on a dozen unrelated boxes — and it measures a second property beside overflow: a flex or grid box shrunk below its own content. That is the failure a screenshot hides and an overflow check misses, because nothing leaves the stage; the boxes just shrink, and slide 2's captions printed through the footnote under them. Twenty-nine slides, no overflow and no squeeze at 1280, 1600 and 2560, no console errors, both Zen faces loaded; hyperframes check passes with 173/173 contrast. deck/assets/logos/ was a second copy of the artwork MARK already holds inline, and nothing read it. The laptop shot is retaken without the site's own navigation bar cut across the top of it.
110 lines
5.6 KiB
JavaScript
110 lines
5.6 KiB
JavaScript
// Page the deck the way a reader does, and photograph it. Two things a
|
||
// screenshot cannot tell you are measured here instead: whether any child
|
||
// overflows the 1920×1080 stage at a width nobody designed at, and whether the
|
||
// faces that actually painted came from @hanzo/font.
|
||
//
|
||
// node deck/shoot.mjs # measures; writes deck/snapshots/
|
||
//
|
||
// Run it from the repository root — the deck has no dependencies of its own and
|
||
// @playwright/test resolves there. The directory it serves is this one, so what
|
||
// is measured is exactly what a flat server hands a browser.
|
||
import { chromium } from '@playwright/test'
|
||
import http from 'node:http'
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import { dirname, join } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url))
|
||
const out = process.argv[2] ?? join(here, 'snapshots')
|
||
const TYPE = {
|
||
'.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json',
|
||
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2',
|
||
}
|
||
const srv = http.createServer((q, r) => {
|
||
const rel = decodeURIComponent(q.url.split('?')[0])
|
||
const f = join(here, rel === '/' ? 'index.html' : rel)
|
||
if (!f.startsWith(here) || !fs.existsSync(f)) { r.writeHead(404).end(); return }
|
||
r.writeHead(200, { 'content-type': TYPE[path.extname(f)] ?? 'application/octet-stream' })
|
||
fs.createReadStream(f).pipe(r)
|
||
}).listen(0)
|
||
await new Promise((r) => srv.once('listening', r))
|
||
const url = `http://127.0.0.1:${srv.address().port}/`
|
||
fs.mkdirSync(out, { recursive: true })
|
||
|
||
let failed = 0
|
||
const browser = await chromium.launch()
|
||
for (const w of [2560, 1600, 1280]) {
|
||
const pg = await browser.newPage({ viewport: { width: w, height: Math.round((w * 9) / 16) }, deviceScaleFactor: 1 })
|
||
const errors = []
|
||
pg.on('console', (m) => m.type() === 'error' && errors.push(m.text()))
|
||
pg.on('pageerror', (e) => errors.push(String(e)))
|
||
await pg.goto(url, { waitUntil: 'networkidle' })
|
||
await pg.waitForFunction(() => document.fonts.status === 'loaded')
|
||
// The faces reporting loaded is not the reflow they cause. Measuring in the
|
||
// frame after that flag flips reads pre-reflow metrics, which is why the same
|
||
// page measured clean at one width and squeezed at the next.
|
||
await pg.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))))
|
||
await pg.waitForTimeout(250)
|
||
const n = await pg.evaluate(() => document.querySelectorAll('.slide').length)
|
||
|
||
if (w === 2560) {
|
||
const faces = await pg.evaluate(() => [...document.fonts].map((f) => `${f.family} ${f.status}`))
|
||
console.log(`faces ${faces.join(' · ')}`)
|
||
// The pager starts on the first slide, so photograph BEFORE advancing —
|
||
// pressing first would name slide two `s01` and never show slide one.
|
||
for (let i = 0; i < n; i++) {
|
||
await pg.waitForTimeout(90)
|
||
await pg.screenshot({ path: join(out, `${String(i + 1).padStart(2, '0')}.png`) })
|
||
await pg.keyboard.press('ArrowRight')
|
||
}
|
||
}
|
||
|
||
// Two measurements a screenshot will not give you, each slide taken at its
|
||
// OWN still frame. Measuring the lot in one pass reads the paused timeline's
|
||
// opening state — every part of every slide it has not reached yet is still
|
||
// held 22px down by its own entrance — and that offset lands in the numbers as
|
||
// a phantom 22 on a dozen unrelated boxes. Seeking first is also what a reader
|
||
// and a renderer both do, so this measures the frame they will see.
|
||
//
|
||
// OVERFLOW: every child against the stage's own rect. The stage is scaled, so
|
||
// this is in painted pixels and a fraction is rounding — a whole pixel is a
|
||
// slide that is off the edge.
|
||
//
|
||
// SQUEEZE: a slide is a flex column, so when its contents are too tall the
|
||
// BOXES shrink and stay inside the stage while the TEXT prints straight
|
||
// through the caption below it. Nothing overflows and the page looks wrong,
|
||
// which is exactly the shape this was blind to on slide 2 — a band whose
|
||
// labels ran into the footnote. Flex and grid boxes only: those are the ones
|
||
// that can be shrunk, and a heading set at line-height 1.03 reports a taller
|
||
// scroll box than its own line without anything having gone wrong.
|
||
const bad = await pg.evaluate(() => {
|
||
const tl = window.__timelines.deck
|
||
const slides = [...document.querySelectorAll('.slide')]
|
||
const hold = Number(slides[1].dataset.start) - Number(slides[0].dataset.start)
|
||
const over = [], tight = []
|
||
slides.forEach((s, i) => {
|
||
tl.seek(i * hold + hold / 2, false)
|
||
tl.pause()
|
||
const r = s.getBoundingClientRect()
|
||
for (const e of s.querySelectorAll('*')) {
|
||
const b = e.getBoundingClientRect()
|
||
if (!b.width && !b.height) continue
|
||
if (b.right > r.right + 1 || b.bottom > r.bottom + 1 || b.left < r.left - 1 || b.top < r.top - 1)
|
||
over.push(`${s.id}·${e.className || e.tagName} r${(b.right - r.right).toFixed(0)} b${(b.bottom - r.bottom).toFixed(0)}`)
|
||
if (/flex|grid/.test(getComputedStyle(e).display) && e.scrollHeight > e.clientHeight + 2)
|
||
tight.push(`${s.id}·${e.className || e.tagName} +${e.scrollHeight - e.clientHeight}`)
|
||
}
|
||
})
|
||
return { over, tight }
|
||
})
|
||
failed += bad.over.length + bad.tight.length + errors.length
|
||
console.log(`${w}px slides=${n} overflow=${bad.over.length ? bad.over.slice(0, 8).join(' ; ') : 'none'}` +
|
||
` squeezed=${bad.tight.length ? bad.tight.slice(0, 8).join(' ; ') : 'none'}` +
|
||
` errors=${errors.length ? errors.join(' | ') : 'none'}`)
|
||
await pg.close()
|
||
}
|
||
await browser.close()
|
||
srv.close()
|
||
process.exit(failed ? 1 : 0)
|