Files
zeekayandHanzo Dev 51bcb189d8 the design-system layer, and the pipeline that gets the site onto it
src/styles/index.css now imports exactly three things: @hanzo/design's tokens,
system.css (this site's role vocabulary written entirely in those tokens), and
site.css (the few treatments this surface owns). No utility framework, no
per-page stylesheet, no fourth layer.

Deleted here because this layer is what replaces them: base.css, typography.css,
themes.css and utilities.css were a hand-rolled Tailwind substitute — a second
colour scale, a second type ramp, a second set of utilities — and App.css was
imported by nothing.

scripts/migrate.sh runs the translation end to end against the ORIGINAL sources
in git, so the codemod can be corrected and re-run without compounding earlier
passes, which it was, repeatedly. detailwind.mjs translates whole class SETS
rather than token by token, because the responsive behaviour belongs in the
class and not copy-pasted at 48,000 call sites. sweep-residual.mjs reuses its
translate() for the class names that live in data files the className walk
cannot see. css-check.mjs and shots.mjs are the checks: used-vs-defined class
coverage, and Playwright over all 93 routes at 390px and 1280px.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 14:22:30 -07:00

170 lines
5.3 KiB
JavaScript

// sensei.group: route screenshots + a mobile-overflow / touch-target audit at 390px.
// Usage: node scripts/shots.mjs <outDir> [baseUrl]
import { chromium } from 'playwright'
import { mkdirSync, writeFileSync } from 'node:fs'
const out = process.argv[2] || 'shots'
const base = process.argv[3] || 'http://127.0.0.1:4173'
const routes = [
'/',
'/original',
'/home2',
'/index',
'/pricing',
'/login',
'/signup',
'/status',
'/leadership',
'/referrals',
'/open-source',
'/team',
'/security',
'/contact',
'/enterprise',
'/zen',
'/zen/models',
'/zen-models',
'/philosophy',
'/zen-of-hanzo',
'/brand',
'/press',
'/solutions',
'/solutions/capabilities',
'/solutions/industries',
'/products',
'/products/integrations',
'/ai',
'/datastore',
'/commerce',
'/extension',
'/base',
'/download',
'/analytics',
'/cloud',
'/edge',
'/platform',
'/operative',
'/app',
'/bot',
'/code',
'/dev',
'/blockchain',
'/web3',
'/blockchain/nodes',
'/blockchain/indexer',
'/blockchain/wallet',
'/blockchain/safe',
'/blockchain/id',
'/blockchain/pay',
'/blockchain/bridge',
'/blockchain/defi',
'/blockchain/oracle',
'/blockchain/storage',
'/blockchain/explorer',
'/blockchain/quest',
'/vector',
'/payments',
'/functions',
'/identity',
'/machines',
'/realtime',
'/ai-studio',
'/automations',
'/dashboard',
'/user-profile',
'/organization-profile',
'/account',
'/account/organization',
'/account/billing',
'/account/purchase-credits',
'/account/billing-plans',
'/account/referrals',
'/account/usage',
'/account/settings',
'/account/invoices',
'/team/dev',
'/team/vi',
'/team/opera',
'/team/chat',
'/team/db',
'/team/su',
'/team/art',
'/team/mu',
'/team/data',
'/team/core',
'/team/fin',
'/team/sec',
'/team/algo',
'/team/des',
'/team/mark',
'/team/cal',
'/nope-404',
]
mkdirSync(out, { recursive: true })
const browser = await chromium.launch()
const report = []
for (const [label, width, height] of [['m', 390, 844], ['d', 1280, 900]]) {
const ctx = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 1,
isMobile: label === 'm',
hasTouch: label === 'm',
})
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(String(e).slice(0, 200)))
for (const r of routes) {
const name = r === '/' ? 'root' : r.slice(1).replace(/\//g, '_')
errors.length = 0
await page.goto(base + r, { waitUntil: 'networkidle', timeout: 45000 }).catch(() => {})
await page.waitForTimeout(500)
const audit = await page.evaluate((vw) => {
const de = document.documentElement
const overflow = Math.max(de.scrollWidth, document.body.scrollWidth) - vw
const wide = []
if (overflow > 1) {
for (const el of document.querySelectorAll('body *')) {
const b = el.getBoundingClientRect()
if (b.width > 0 && b.right > vw + 1 && b.left < vw) {
const cs = getComputedStyle(el)
if (cs.position === 'fixed' || cs.overflowX === 'auto' || cs.overflowX === 'scroll') continue
wide.push(`${el.tagName.toLowerCase()}.${(el.className || '').toString().slice(0, 60)} w=${Math.round(b.width)} r=${Math.round(b.right)}`)
if (wide.length > 6) break
}
}
}
const small = []
for (const el of document.querySelectorAll('a,button,[role=button],input,select,summary')) {
const b = el.getBoundingClientRect()
if (b.width === 0 || b.height === 0) continue
const cs = getComputedStyle(el)
if (cs.display === 'inline' || cs.visibility === 'hidden') continue
if (b.height < 44 || b.width < 24) {
small.push(`${el.tagName.toLowerCase()}[${(el.textContent || '').trim().slice(0, 24)}] ${Math.round(b.width)}x${Math.round(b.height)}`)
}
}
// "Empty" has to mean the page rendered nothing, not that it is short: a
// login form is 109 characters of real, interactive page. So a page counts
// as empty only when it has no heading, no control AND almost no text.
const headings = document.querySelectorAll('h1,h2,h3').length
const controls = document.querySelectorAll('a,button,[role=button],input,select').length
const text = document.body.innerText.length
return { overflow, wide, smallCount: small.length, small: small.slice(0, 12), text,
headings, controls, empty: text < 200 && !headings && !controls }
}, width)
await page.screenshot({ path: `${out}/${label}-${name}.png`, fullPage: false })
report.push({ view: label, route: r, ...audit, errors: [...errors] })
}
await ctx.close()
}
await browser.close()
writeFileSync(`${out}/report.json`, JSON.stringify(report, null, 1))
const bad = report.filter((r) => r.view === 'm' && r.overflow > 1)
console.log('routes:', routes.length)
console.log('mobile horizontal overflow on:', bad.length, bad.map((b) => `${b.route}(+${b.overflow})`).join(' '))
console.log('touch targets <44px tall (mobile):', report.filter((r) => r.view === 'm').reduce((a, b) => a + b.smallCount, 0))
console.log('empty pages:', report.filter((r) => r.empty).map((r) => r.view + r.route).join(' ') || 'none')
console.log('page errors:', report.filter((r) => r.errors.length).map((r) => r.view + r.route + ':' + r.errors[0]).join(' | ') || 'none')