merge: origin/main (the console's typeface)

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-02 11:32:24 -07:00
co-authored by Hanzo Dev
15 changed files with 1673 additions and 43 deletions
+1
View File
@@ -8,6 +8,7 @@
*.db-shm
.env
ui/dist/
ui/dist-offline/
ui/node_modules/
ui/tsconfig.tsbuildinfo
ui/test-results/
+1 -1
View File
@@ -1 +1 @@
default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.hanzo.ai https://hanzo.id; manifest-src 'self'
default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='; img-src 'self' data:; font-src 'self' https://cdn.hanzo.ai; connect-src 'self' https://api.hanzo.ai https://hanzo.id; manifest-src 'self'
+53
View File
@@ -0,0 +1,53 @@
// Our CDN, answered locally.
//
// The console fetches its two faces from cdn.hanzo.ai. That is a real origin
// this machine may or may not be able to reach, and a suite whose result
// depends on which is a suite that reports on the network. So the request is
// still MADE — it still has to pass `font-src`, it is still cross-origin, and
// the tests that count cross-origin requests still see it — and it is answered
// here from the `geist` package at the version the bundle pins.
//
// Same reasoning as the issuer in session.ts: keep the run offline without
// relaxing anything.
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import type { Page } from '@playwright/test'
import { cdn, dir, faces } from '../src/font'
const root = dirname(dirname(createRequire(import.meta.url).resolve('geist/font')))
/** The bytes the CDN is expected to be serving, keyed by the path they live at. */
const bytes = new Map(
faces.map((f) => [`${dir}/${f.file}`, readFileSync(join(root, 'dist', 'fonts', f.cut, f.from))]),
)
/**
* Serve the faces the way the CDN serves them.
*
* `access-control-allow-origin: *` is not decoration: a cross-origin face is
* always fetched in CORS mode, so a font host without that header serves
* nothing usable however correct its CSP entry is. cdn.hanzo.ai returns it,
* which is why it is a font host and not merely a static one.
*
* Anything else under that host is a 404, so a test can prove the console asks
* it for the two files and for nothing else.
*/
export async function serveFonts(page: Page) {
await page.route(`${cdn}/**`, (route) => {
const body = bytes.get(new URL(route.request().url()).pathname)
if (!body) return route.fulfill({ status: 404, body: 'not found' })
return route.fulfill({
status: 200,
headers: {
'content-type': 'font/woff2',
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=31536000, immutable',
},
body,
})
})
}
+290
View File
@@ -0,0 +1,290 @@
// The typeface: that it arrives, that it is the one on the screen, and what it
// costs the policy.
//
// A font is the easiest thing in a front end to be wrong about quietly. A
// family the browser cannot resolve is not an error — it silently takes the
// default, which is a serif, and everything renders. A stack written correctly
// but never loaded looks identical to a stack that loaded. `getComputedStyle`
// reports the LIST that was asked for and says nothing about which entry in it
// the glyphs came from, so a test written against it passes just as happily
// when the woff2 404s.
//
// So the load-bearing assertions here go around it, to the two places that
// cannot be talked into agreeing:
//
// - `CSS.getPlatformFontsForNode`, which reports the face the renderer
// actually rasterised those glyphs with. If Geist did not arrive, this says
// Times New Roman, whatever the stylesheet says.
// - the request log, which says where the bytes came from and how often.
//
// The faces are served by e2e/cdn.ts, from the `geist` package at the version
// the bundle pins, so the run is offline and the cross-origin request is still
// genuinely made and still has to pass `font-src`.
import { expect, test, type Page } from '@playwright/test'
import { readFileSync } from 'node:fs'
import { cdn, dir, faces, lead } from '../src/font'
import { signIn } from './session'
const csp = readFileSync(new URL('../csp.txt', import.meta.url), 'utf8').trim()
/** The two stacks as the document holds them, and the family each one leads with. */
async function stacks(page: Page) {
const [sans, mono] = await page.evaluate(() => {
const root = getComputedStyle(document.documentElement)
return [root.getPropertyValue('--hz-font-sans').trim(), root.getPropertyValue('--hz-font-mono').trim()]
})
return { sans, mono, sansFace: lead(sans), monoFace: lead(mono) }
}
/**
* Which face the renderer actually used for an element's glyphs.
*
* The node has to be one that carries text of its own — the report is about
* glyphs that were laid out, so an element whose text all lives in children
* (`body`, most containers) reports nothing at all.
*/
async function rendered(page: Page, selector: string): Promise<string> {
await expect(page.locator(selector).first()).not.toBeEmpty()
const cdp = await page.context().newCDPSession(page)
await cdp.send('DOM.enable')
await cdp.send('CSS.enable')
const { root } = await cdp.send('DOM.getDocument')
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector })
expect(nodeId, `no ${selector} on the page`).toBeTruthy()
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId })
expect(fonts.length, `nothing was rasterised for ${selector}`).toBeGreaterThan(0)
// The face that set most of the glyphs; a stray glyph off a fallback is not
// what the element is set in.
return [...fonts].sort((a, b) => b.glyphCount - a.glyphCount)[0].familyName
}
test.describe('the typeface', () => {
test('both faces load, from our CDN, once each', async ({ page }) => {
const seen: Array<{ url: string; type: string }> = []
page.on('request', (r) => seen.push({ url: r.url(), type: r.resourceType() }))
await signIn(page)
await page.goto('/cases')
await expect(page.locator('nav.rail')).toBeVisible()
await page.evaluate(() => document.fonts.ready)
// Installed and loaded — not merely declared. @font-face inside a
// constructed stylesheet is the whole delivery mechanism (src/gui.ts), and
// this is the assertion that it installs a real face rather than being
// parsed and dropped.
const { sansFace, monoFace } = await stacks(page)
const loaded = await page.evaluate(() =>
[...document.fonts].map((f) => `${f.family} ${f.status}`).sort(),
)
expect(loaded).toEqual([`${sansFace} loaded`, `${monoFace} loaded`].sort())
// Once each. Both files are preloaded; a preload whose CORS mode disagrees
// with the fetch that follows is not a saved request but a duplicated one,
// and the only way to tell is to count.
for (const f of faces) {
const hits = seen.filter((r) => r.url === `${cdn}${dir}/${f.file}`)
expect(hits.length, `${f.file} was fetched ${hits.length} times`).toBe(1)
expect(hits[0].type).toBe('font')
}
})
test('the console is set in Geist, and its machine text in Geist Mono', async ({ page }) => {
await signIn(page)
await page.goto('/cases')
await expect(page.locator('nav.rail')).toBeVisible()
await page.evaluate(() => document.fonts.ready)
// What the stylesheet asks for. Geist Sans is the UI face and Geist Mono
// the monospace one across the fleet, so the two stacks are pinned by name
// and not merely to each other — and the tail is pinned too, because the
// whole point of the fallback is what happens when Geist does not arrive
// and the one answer that is never acceptable is the browser's default.
const { sans, mono, sansFace, monoFace } = await stacks(page)
expect(sansFace, 'the UI face is not Geist').toMatch(/^Geist( Sans)?$/)
expect(monoFace, 'the monospace face is not Geist Mono').toBe('Geist Mono')
expect(sans, 'the sans stack can fall back to a serif').toMatch(/(^|,\s*)(ui-sans-serif|system-ui)\b/)
expect(sans.trimEnd()).toMatch(/sans-serif$/)
expect(mono.trimEnd(), 'the mono stack can fall back to a serif').toMatch(/monospace$/)
const body = await page.evaluate(() => getComputedStyle(document.body).fontFamily)
expect(lead(body), 'body is not set in the sans stack').toBe(sansFace)
// …and what the renderer did with it. These are the ones that a 404 on the
// CDN, a face registered under a name the stack never asks for, or a rule
// that was parsed and dropped would each break — and that no amount of
// correct CSS can fake, because the answer comes from the rasteriser.
//
// What it reports is the family name INSIDE the woff2, which is not the
// name the @font-face registered it under: a CSS family name is an alias,
// and Vercel ships the UI face with `Geist` in it whatever a stack chooses
// to call it. So these two pin the FILE, the assertions above pin the
// ALIAS, and together they say the stack asked for a face and was given
// this one. They are also what separates "Geist arrived" from "something
// arrived": a fallback here reads DejaVu Sans, or Times New Roman.
//
// The heading takes its family from nothing but the document, so what it is
// set in is what body resolved to; it is measured instead of body because
// an element whose text is all in its children rasterises no glyphs itself.
expect(await rendered(page, '.bar h1'), 'the UI is not set in Geist').toBe('Geist')
expect(await rendered(page, '.grid .id'), 'an id is not set in Geist Mono').toBe('Geist Mono')
// The seam. `ui.tsx` exports a Mono that is a KIT component carrying the
// same class, and the kit dresses its own components with single-class
// atomic rules in a stylesheet the cascade places after app.css. A bare
// `.mono` ties with those on specificity and loses on order, so machine
// text asked for through the kit came out in the UI face — a rate in
// proportional digits, in a column, which is exactly where it shows.
//
// Asked of a real kit element rather than of a fixture, because which
// screens happen to render a Mono depends on which data came back, and the
// cascade does not. Marking one is a CSSOM call from a script that already
// passed script-src, so the policy is not involved.
const seam = await page.evaluate(() => {
// `.font_body` is precisely the class the kit's own font-family rule is
// written against, so this is the element the collision is about and not
// one that merely happens to be near it.
const kit = document.querySelector<HTMLElement>('.font_body')
if (!kit) return null
const was = getComputedStyle(kit).fontFamily
kit.classList.add('mono')
return { was, now: getComputedStyle(kit).fontFamily }
})
expect(seam, 'no kit-rendered element was found to test the seam with').not.toBeNull()
expect(lead(seam!.was), 'a kit element is not in the sans stack to begin with').toBe(sansFace)
expect(lead(seam!.now), 'a kit element marked as machine text is not in the mono stack').toBe(monoFace)
})
test('quantities are set on a fixed advance', async ({ page }) => {
await signIn(page)
// The overview, because it is the screen that has both kinds at once: cells
// a table calls a quantity, and stat tiles, which are not in a table and
// need it just as much.
await page.goto('/')
await expect(page.locator('nav.rail')).toBeVisible()
const quantities = page.locator('.num, .tile-value')
// Counted before it is judged. A selector that matches nothing makes the
// assertion below true and says nothing at all, which is the way a check
// like this rots: the markup moves, the test keeps passing, and the column
// it was written for has been ragged for a year.
await expect(quantities.first()).toBeVisible()
expect(await quantities.count(), 'no quantity was on screen to judge').toBeGreaterThanOrEqual(5)
const off = await quantities.evaluateAll((els) =>
els
.map((el) => ({ text: (el.textContent ?? '').trim().slice(0, 16), v: getComputedStyle(el).fontVariantNumeric }))
.filter((x) => !x.v.includes('tabular-nums')),
)
expect(off, 'a quantity is set in proportional figures').toEqual([])
})
test('the faces are the only thing this console loads from anywhere else', async ({ page }) => {
const seen: Array<{ url: string; type: string }> = []
page.on('request', (r) => seen.push({ url: r.url(), type: r.resourceType() }))
await signIn(page)
await page.goto('/')
await expect(page.locator('nav.rail')).toBeVisible()
await page.evaluate(() => document.fonts.ready)
const here = new URL(page.url()).origin
const away = seen.filter((r) => new URL(r.url).origin !== here)
// Two kinds of cross-origin traffic, and the policy already draws the line
// between them: `connect-src` governs what the console ASKS (the identity
// it was already allowed to reach), `font-src` governs what it LOADS. This
// change adds to the second, so that is what is pinned: every subresource
// fetched from another origin is a font, and every one of them is ours.
const loads = away.filter((r) => r.type !== 'xhr' && r.type !== 'fetch')
expect([...new Set(loads.map((r) => new URL(r.url).origin))]).toEqual([cdn])
expect([...new Set(loads.map((r) => r.type))]).toEqual(['font'])
// And the CDN is asked for the two files and for nothing else — no
// stylesheet, no script, no beacon riding along on the host we just let in.
const paths = [...new Set(away.filter((r) => new URL(r.url).origin === cdn).map((r) => new URL(r.url).pathname))]
expect(paths.sort()).toEqual(faces.map((f) => `${dir}/${f.file}`).sort())
})
test('no face, and no preload, is refused by the policy', async ({ page }) => {
const refusals: string[] = []
await page.addInitScript(() => {
const store: string[] = []
;(window as unknown as { __csp: string[] }).__csp = store
document.addEventListener('securitypolicyviolation', (e) =>
store.push(`${e.violatedDirective} blocked ${e.blockedURI || '(inline)'}`),
)
})
page.on('console', (m) => {
if (/Content Security Policy|Refused to (apply|load|execute)/i.test(m.text())) refusals.push(m.text())
})
page.on('requestfailed', (r) => refusals.push(`failed ${r.url()} ${r.failure()?.errorText}`))
await signIn(page)
await page.goto('/cases')
await expect(page.locator('nav.rail')).toBeVisible()
await page.evaluate(() => document.fonts.ready)
expect(await page.evaluate(() => (window as unknown as { __csp: string[] }).__csp)).toEqual([])
expect(refusals).toEqual([])
})
test('the two URLs are the ones the fleet publishes', () => {
// Written out in full, on purpose, and this is the one place in the suite
// that does not derive them from src/font.ts.
//
// These are not this console's to choose. They are the fleet's: the same
// two files, at the same two URLs, cached once for every Hanzo property,
// published to our CDN by the kit track. A console that quietly renames a
// file still passes every other test here — its own mock serves whatever it
// asks for — and then 404s in production against the real CDN. So the
// contract is stated where changing it means editing the agreement.
//
// Immutable by version: nothing at these paths is ever rewritten, which is
// what earns them `max-age=31536000, immutable`. A new Geist is a new
// directory beside this one, never a new file inside it.
expect(faces.map((f) => `${cdn}${dir}/${f.file}`)).toEqual([
'https://cdn.hanzo.ai/fonts/geist/1.7.2/GeistVariable.woff2',
'https://cdn.hanzo.ai/fonts/geist/1.7.2/GeistMonoVariable.woff2',
])
})
test('the CDN costs the policy exactly one host, in exactly one directive', () => {
// The policy is the deployed one (e2e/serve.mjs sends this file verbatim),
// so this is a check on what ships. A font host is the cheapest possible
// widening of a CSP and also the easiest to widen by more than intended —
// one `*` or one extra directive and the fix has cost more than the bug.
const directives = new Map(
csp
.split(';')
.map((d) => d.trim())
.filter(Boolean)
.map((d) => {
const [name, ...value] = d.split(/\s+/)
return [name, value.join(' ')] as const
}),
)
expect(directives.get('font-src')).toBe("'self' https://cdn.hanzo.ai")
expect(directives.get('default-src')).toBe("'none'")
expect(directives.get('style-src')).toBe("'self' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='")
expect(directives.get('script-src')).toBe("'self'")
expect(csp).not.toContain('unsafe-inline')
expect(csp).not.toContain('unsafe-eval')
expect(csp).not.toContain('*')
// The CDN is in font-src and nowhere else: it may serve this console faces
// and it may not serve it code, style, or a place to send data.
for (const [name, value] of directives) {
if (name === 'font-src') continue
expect(value, `${name} names the CDN`).not.toContain('cdn.hanzo.ai')
}
// Every remote host the whole policy names, so a fourth cannot appear
// without this failing.
const hosts = [...new Set([...csp.matchAll(/https:\/\/[^\s;]+/g)].map((m) => m[0]))].sort()
expect(hosts).toEqual(['https://api.hanzo.ai', 'https://cdn.hanzo.ai', 'https://hanzo.id'])
})
})
+22 -5
View File
@@ -156,14 +156,31 @@ test.describe('the kit reaches the screens', () => {
// variable substituted in a scope that does not hold it, both land outside
// them. It reads the resolved family off elements the kit owns and elements
// it does not, because the two are dressed on opposite sides of the seam.
const families = await page
//
// The stacks are read from the document rather than written out here: they
// are @hanzo/gui's, and a copy of them in a test is one more place to drift
// from the kit. That the console is set in GEIST, and that the faces
// actually arrive and are what the renderer used, is e2e/font.spec.ts.
const { families, sans, mono } = await page
.locator('body, nav.rail, h1, h2, button, input, select, td, .grid .id, code')
.evaluateAll((els) =>
[...new Set(els.map((el) => getComputedStyle(el).fontFamily))].sort(),
)
.evaluateAll((els) => {
const root = getComputedStyle(document.documentElement)
return {
families: [...new Set(els.map((el) => getComputedStyle(el).fontFamily))].sort(),
sans: root.getPropertyValue('--hz-font-sans').trim(),
mono: root.getPropertyValue('--hz-font-mono').trim(),
}
})
expect(families.length, 'nothing was measured').toBeGreaterThan(0)
const stray = families.filter((f) => !/^ui-sans-serif,|^ui-monospace,/.test(f))
expect(sans, 'the console declares no sans stack').not.toBe('')
expect(mono, 'the console declares no mono stack').not.toBe('')
// Compared on the leading family, because the kit and the stylesheet quote
// and space a stack differently while meaning the same list.
const first = (f: string) => (f.split(',')[0] ?? '').trim().replace(/^["']|["']$/g, '')
const wanted = new Set([first(sans), first(mono)])
const stray = families.filter((f) => !wanted.has(first(f)))
expect(stray, `type is set in ${stray.join(' / ')}`).toEqual([])
})
+100
View File
@@ -0,0 +1,100 @@
// The console with no way out: the air-gapped build.
//
// Some deployments of this console sit inside a bank's perimeter and cannot
// reach cdn.hanzo.ai, or anything else of ours. They serve the same two faces
// from their own origin. That is a build argument — `FONT_ORIGIN=` — and not a
// fork: same family names, same token names, same @font-face rules, same
// stylesheet, and `font-src 'self'` already covers it.
//
// An escape hatch nobody exercises is a broken escape hatch, and this one is
// found to be broken by a customer, on an install, with no network to debug it
// over. So it is built and driven here rather than reasoned about: its own
// build, in its own directory, on its own port, with the same strict policy.
import { expect, test } from '@playwright/test'
import { execFileSync, spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { cdn, dir, faces } from '../src/font'
import { signIn } from './session'
// One worker for this file: it owns a build directory and a port, and two
// workers racing to create both would be testing each other.
test.describe.configure({ mode: 'serial' })
const ui = fileURLToPath(new URL('..', import.meta.url))
const OUT = 'dist-offline'
const PORT = 4320
const at = `http://127.0.0.1:${PORT}`
let server: ReturnType<typeof spawn>
test.beforeAll(async () => {
execFileSync('npx', ['vite', 'build', '--outDir', OUT], {
cwd: ui,
env: { ...process.env, FONT_ORIGIN: '' },
stdio: 'pipe',
})
server = spawn('node', ['e2e/serve.mjs'], {
cwd: ui,
env: { ...process.env, PORT: String(PORT), ROOT: OUT },
stdio: 'pipe',
})
// Up when it answers, rather than after a sleep.
const until = Date.now() + 30_000
for (;;) {
try {
if ((await fetch(`${at}/v1/aml/health`)).ok) break
} catch {
/* not yet */
}
if (Date.now() > until) throw new Error('the offline build never came up')
await new Promise((r) => setTimeout(r, 100))
}
})
test.afterAll(() => server?.kill())
test.describe('the air-gapped build', () => {
test('serves its own faces, and reaches nothing', async ({ page }) => {
const seen: string[] = []
const refused: string[] = []
page.on('request', (r) => seen.push(r.url()))
page.on('console', (m) => {
if (/Content Security Policy|Refused to (apply|load|execute)/i.test(m.text())) refused.push(m.text())
})
page.on('requestfailed', (r) => refused.push(`failed ${r.url()}`))
await signIn(page)
await page.goto(at)
await expect(page.locator('nav.rail')).toBeVisible()
await page.evaluate(() => document.fonts.ready)
// The same two faces, installed and loaded — from here.
const loaded = await page.evaluate(() => [...document.fonts].map((f) => f.status).sort())
expect(loaded).toEqual(['loaded', 'loaded'])
for (const f of faces) {
expect(seen, `${f.file} was not served from this origin`).toContain(`${at}${dir}/${f.file}`)
}
// And nothing at all left the building. `hanzo.id` is the exception the
// console is entitled to — it is the identity provider, and an install
// behind a perimeter points it at one inside the perimeter — but our CDN
// must not be reached for, not even to fail.
expect(seen.filter((u) => u.startsWith(cdn)), 'the air-gapped build still asks our CDN').toEqual([])
expect(refused).toEqual([])
})
test('the licence travels with the files', async () => {
// Geist is Vercel's, under the SIL Open Font License 1.1, which permits
// redistribution and requires the notice to go with it. Hosting the bytes
// without the licence beside them is the one way this ships wrong, and it
// is invisible until somebody asks.
const ofl = await fetch(`${at}${dir}/OFL.txt`)
expect(ofl.status).toBe(200)
const text = await ofl.text()
expect(text).toContain('SIL OPEN FONT LICENSE Version 1.1')
expect(text).toContain('Copyright (c) 2023 Vercel')
})
})
+3 -1
View File
@@ -19,7 +19,9 @@ import { extname, join, normalize, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = fileURLToPath(new URL('.', import.meta.url))
const root = resolve(here, '..', 'dist')
// Which build is being served. The default is the one `npm run build` writes;
// the offline spec points this at its own so the two never share a directory.
const root = resolve(here, '..', process.env.ROOT ?? 'dist')
const csp = (await readFile(resolve(here, '..', 'csp.txt'), 'utf8')).trim()
const port = Number(process.env.PORT ?? 4173)
+8
View File
@@ -7,7 +7,15 @@
import type { Page } from '@playwright/test'
import { serveFonts } from './cdn'
export async function signIn(page: Page) {
// Every origin this console reaches is answered locally, so the run is
// offline and its result is about the console rather than about the network.
// The faces come first because they are fetched at first paint, before
// anything below has been read.
await serveFonts(page)
await page.addInitScript(() => {
const hour = Date.now() + 60 * 60 * 1000
sessionStorage.setItem('hanzo_iam_access_token', 'at-e2e')
+888 -2
View File
@@ -13,6 +13,8 @@
"@hanzogui/core": "8.0.0",
"@hanzogui/elements": "8.0.0",
"@hanzogui/fake-react-native": "8.0.0",
"@hanzogui/font-geist-mono": "8.0.0",
"@hanzogui/font-geist-sans": "8.0.0",
"@hanzogui/input": "8.0.0",
"@hanzogui/label": "8.0.0",
"@hanzogui/scroll-view": "8.0.0",
@@ -28,6 +30,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.5",
"geist": "1.7.2",
"typescript": "^5.7.0",
"vite": "^8.2.0"
}
@@ -984,6 +987,24 @@
"react": ">=19"
}
},
"node_modules/@hanzogui/font-geist-mono": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@hanzogui/font-geist-mono/-/font-geist-mono-8.0.0.tgz",
"integrity": "sha512-sfeN1yuSSBIXaS1NTgrl2e26Yip5XR3aPb4ObzMShVkvRPyLFoW3upPskC9xgCB+S5Ct5++9UxY9BiLjBLnhSg==",
"license": "BSD-3-Clause",
"dependencies": {
"@hanzogui/core": "8.0.0"
}
},
"node_modules/@hanzogui/font-geist-sans": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@hanzogui/font-geist-sans/-/font-geist-sans-8.0.0.tgz",
"integrity": "sha512-4nYOha3m/k0h0N8r9J2h7V0g7atRdS1VsVXQZwv/t11BR+WxkNgbCqFuKUXydj+i07YtlPvopRdHO54p5/r1OA==",
"license": "BSD-3-Clause",
"dependencies": {
"@hanzogui/core": "8.0.0"
}
},
"node_modules/@hanzogui/font-size": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@hanzogui/font-size/-/font-size-8.0.0.tgz",
@@ -1905,6 +1926,534 @@
"react": ">=19"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/ttlcache": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz",
@@ -2029,6 +2578,158 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
}
},
"node_modules/@next/env": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz",
"integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz",
"integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz",
"integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz",
"integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz",
"integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz",
"integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz",
"integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz",
"integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz",
"integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@oxc-project/types": {
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
@@ -2492,6 +3193,17 @@
"license": "MIT",
"peer": true
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -2914,6 +3626,14 @@
"license": "MIT",
"peer": true
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -3272,6 +3992,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/geist": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/geist/-/geist-1.7.2.tgz",
"integrity": "sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==",
"dev": true,
"license": "SIL OPEN FONT LICENSE",
"peerDependencies": {
"next": ">=13.2.0"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -4385,6 +5115,91 @@
"node": ">= 0.6"
}
},
"node_modules/next": {
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz",
"integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "16.2.12",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.12",
"@next/swc-darwin-x64": "16.2.12",
"@next/swc-linux-arm64-gnu": "16.2.12",
"@next/swc-linux-arm64-musl": "16.2.12",
"@next/swc-linux-x64-gnu": "16.2.12",
"@next/swc-linux-x64-musl": "16.2.12",
"@next/swc-win32-arm64-msvc": "16.2.12",
"@next/swc-win32-x64-msvc": "16.2.12",
"sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -4962,6 +5777,53 @@
"license": "ISC",
"peer": true
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5097,6 +5959,31 @@
"node": ">=8"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -5226,8 +6113,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "0.7.1",
+3
View File
@@ -18,6 +18,8 @@
"@hanzogui/core": "8.0.0",
"@hanzogui/elements": "8.0.0",
"@hanzogui/fake-react-native": "8.0.0",
"@hanzogui/font-geist-mono": "8.0.0",
"@hanzogui/font-geist-sans": "8.0.0",
"@hanzogui/input": "8.0.0",
"@hanzogui/label": "8.0.0",
"@hanzogui/scroll-view": "8.0.0",
@@ -33,6 +35,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.5",
"geist": "1.7.2",
"typescript": "^5.7.0",
"vite": "^8.2.0"
}
+53 -15
View File
@@ -22,16 +22,28 @@
* attribute rather than a style.
*/
/* Values that are the console's own and answer to no theme: the typefaces, the
four state steps, the type scale, and the two fixed dimensions of the frame.
A typeface is not a theme — the same two stacks serve dark and light — so
they are declared here, where every element can reach them. They were in the
theme bridge below, which is on .shell and .gate; body is outside both, so
`font-family: var(--hz-font-sans)` there substituted nothing, the declaration
was invalid, and the document fell back to the browser's default serif. */
/* The typeface floor, and only the floor.
*
* The typeface itself is @hanzo/gui's: Geist Sans and Geist Mono, whose stacks
* come from the kit's font packages and are declared at :root, with the
* @font-face rules that install them, by src/gui.ts. These two are what is left
* if that never happens — a browser with no constructable stylesheets — and
* they exist because the alternative is not "no typeface" but the browser's
* default, which is a serif. That is the bug this console shipped with: a
* font-family whose variable was not in scope is not an error, it is Times New
* Roman.
*
* On `html` rather than :root so that SPECIFICITY, not source order, decides:
* `:root` is (0,1,0) and `html` is (0,0,1), so the real stacks win wherever in
* the cascade an adopted stylesheet happens to land. */
html {
--hz-font-sans: system-ui, sans-serif;
--hz-font-mono: ui-monospace, monospace;
}
/* Values that are the console's own and answer to no theme: the four state
steps, the type scale, and the two fixed dimensions of the frame. */
:root {
--hz-font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--hz-font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
--hz-text-xs: 11px;
--hz-text-sm: 13px;
--hz-text-lg: 16px;
@@ -118,12 +130,40 @@ body {
-webkit-font-smoothing: antialiased;
}
code,
kbd,
pre,
.mono {
/* Machine text — ids, hashes, code, and anything a screen marks as monospace.
*
* The `:root` prefix is load-bearing rather than decorative. `ui.tsx` exports a
* Mono that is a KIT component carrying this class, and the kit dresses its own
* with `.font_body, .font_heading, .font_mono, .is_View { font-family:
* var(--f-family) }` — a single class, in an adopted stylesheet, which the
* cascade places after this file. A bare `.mono` ties with that on specificity
* and loses on order, so machine text asked for through the kit came out in the
* UI face: an id or a rate in proportional digits, in a column, which is
* exactly where it shows. `:root :is(…)` is (0,2,0) and settles it on
* specificity, which no ordering can undo.
*
* Ligatures off, because `!=` and `->` in a rule expression must read as the
* two characters they are. */
:root :is(code, kbd, pre, .mono) {
font-family: var(--hz-font-mono);
font-variant-ligatures: none;
font-variant-numeric: tabular-nums;
}
/* A quantity — a count, a score, a proportion, an age, an amount of money.
*
* Tabular figures give every digit the same advance, so a column of them forms
* real columns of units, tens and hundreds, and an order-of-magnitude
* difference is visible before a single number has been read. On a risk console
* that is the difference between scanning a page of alerts and reading it.
*
* Here with the rest of how text is set, not in the table section, because a
* quantity is a quantity wherever it is shown — a stat tile is not in a table
* and needs it just as much. Being in a COLUMN is what makes it right-aligned,
* and that stays with the table. */
.num,
.tile-value {
font-variant-numeric: tabular-nums;
}
a {
@@ -372,7 +412,6 @@ a {
font-size: var(--hz-text-2xl);
font-weight: var(--hz-weight-medium);
letter-spacing: var(--hz-tracking-tight);
font-variant-numeric: tabular-nums;
line-height: var(--hz-leading-none);
}
@@ -420,7 +459,6 @@ a {
.grid .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.grid .id {
+113
View File
@@ -0,0 +1,113 @@
/*
* The typeface this console is set in, and where its bytes come from.
*
* The STACK — which families to try and in what order — is NOT decided here. It
* is @hanzo/gui's, read from the kit's font packages in src/gui.ts, so every
* Hanzo property is set in the same list and this console cannot drift from it.
* What is decided here is DELIVERY: which release, which files, which origin,
* and the @font-face rules that install them.
*
* Geist is Vercel's, published under the SIL Open Font License 1.1. The bytes
* served are the ones in the `geist` package at the version below, pinned — so
* the licence that was read is the licence of the files that ship — and the OFL
* text travels with them: beside the woff2 on the CDN, and emitted beside them
* into the bundle when this console self-hosts (see vite.config.ts).
*
* One variable file per family carries every weight from 100 to 900, which is
* why there are two requests here and not the eighteen a static-weight cut of
* the same two families would need.
*/
/**
* Our own CDN, and the only origin this console will fetch a face from unless
* it is told otherwise.
*
* It is ours — the `cdn` app in front of our S3 — which is the whole difference
* between this and a third-party font host: one copy, one version, one cache,
* every property, and no request to anybody else's infrastructure. It already
* answers with `access-control-allow-origin: *`, which a cross-origin face
* requires and which is the reason a font host cannot simply be any static host.
*/
export const cdn = 'https://cdn.hanzo.ai'
/**
* The Geist release these files are cut from.
*
* It is part of the path, so a new release is a NEW path and never a new copy
* at an old one. That is what lets the files be cached for a year: nothing at
* a given path is ever rewritten, so nothing cached from it can go stale.
*/
export const version = '1.7.2'
/** The immutable directory the two faces live in, on whichever origin serves them. */
export const dir = `/fonts/geist/${version}`
/**
* The two files, in the order first paint needs them: the UI face, then the one
* ids, hashes, code and monospace columns are set in.
*
* `file` is the name the fleet serves and every property fetches — one URL, so
* one cache entry shared across all of them. It is the fleet's name and not the
* upstream one, which is what `cut` and `from` record: where in the `geist`
* package these exact bytes come from, for the self-hosted build and for the
* suite, so both serve what the CDN serves rather than something like it.
*/
export const faces = [
{ file: 'GeistVariable.woff2', cut: 'geist-sans', from: 'Geist-Variable.woff2' },
{ file: 'GeistMonoVariable.woff2', cut: 'geist-mono', from: 'GeistMono-Variable.woff2' },
] as const
export type Face = (typeof faces)[number]
/** Where a face's bytes are, on a given origin. An empty origin is this app's own. */
export const url = (origin: string, face: Face): string => `${origin}${dir}/${face.file}`
/**
* The family a stack leads with, unquoted.
*
* The name an @font-face registers and the name a stack asks for have to be the
* same string or the face is installed and never used — a failure that renders
* perfectly, in the fallback. So neither is written down twice: the stack is the
* kit's, and the face takes its name from the front of it. When the kit renames
* the family, the face is renamed with it and there is nothing to keep in step.
*/
export const lead = (stack: string): string => (stack.split(',')[0] ?? '').trim().replace(/^["']|["']$/g, '')
/**
* The origin this build fetches its faces from, fixed at build time by
* vite.config.ts.
*
* It has to be fixed at build rather than fetched at run: a face is needed for
* the first paint, and anything the console would have to ask for first arrives
* too late to set the first frame in.
*
* A function, not a constant, so importing this module in Node — which
* vite.config.ts and the suite both do, for the paths and the face list — never
* evaluates a symbol that only exists inside the bundle.
*/
declare const __FONT_ORIGIN__: string
export const origin = (): string => __FONT_ORIGIN__
/**
* The @font-face rules that install the two faces, each under the name the
* stack it belongs to leads with.
*
* `font-display: swap` so the text of a risk console is never invisible: it is
* painted immediately in the fallback and re-set when the face arrives. With
* both files preloaded that window is short, and a swap that never happens
* because the network is gone still leaves a readable screen — which is why the
* stack behind Geist ends in a system face and never in a serif.
*
* These go on through the CSSOM with the kit's own stylesheet (src/gui.ts), so
* `style-src` never sees a stylesheet parsed from markup and the served policy
* needs no hash for this and no 'unsafe-inline'.
*/
export const sheet = (from: string, stacks: readonly string[]): string =>
faces
.map(
(f, i) =>
`@font-face{font-family:"${lead(stacks[i])}";` +
`src:url("${url(from, f)}") format("woff2");` +
`font-weight:100 900;font-style:normal;font-display:swap}`,
)
.join('')
+56 -17
View File
@@ -26,23 +26,34 @@
import { getDefaultGuiConfig } from '@hanzogui/config-default'
import { createGui } from '@hanzogui/core'
import { createGeistMonoFont } from '@hanzogui/font-geist-mono'
import { createGeistSansFont } from '@hanzogui/font-geist-sans'
import { origin, sheet } from './font'
/**
* The typeface, spelled the way the web spells it.
* The typeface, taken from the kit rather than spelled again here.
*
* Geist Sans is the UI face and Geist Mono the monospace one across every Hanzo
* property, so the STACK — Geist first, then what to try when it has not
* arrived — is @hanzo/gui's to state and this console's to read. A stack copied
* into an app is a second source of truth: it does not fail when the kit's
* changes, it just quietly stops matching the other hundred properties.
*
* The kit's default config names its families `System` and `Heading`. Those are
* React Native family names: on that platform the OS resolves them, and on this
* one nothing does. The kit emits the name verbatim as `--f-family`, every
* component reads `font-family: var(--f-family)`, and a family no browser knows
* falls back to the default serif — so the whole console rendered in Times New
* Roman while the build, the typecheck, the CSP run and the render tests all
* passed. One stack, given to both roles, is what `System` was asking for.
* falls back to the default serif — which is how the whole console came to
* render in Times New Roman while the build, the typecheck, the CSP run and the
* render tests all passed.
*
* It is the same list as `--hz-font-sans` in app.css, which dresses the plain
* elements the kit does not own; the two are the console's one typeface stated
* on each side of the seam.
* `mono` is a third role rather than a spare: src/ui.tsx asks the kit for
* `$mono` on the one control whose content is machine text, and a font token
* the config does not define resolves to nothing at all.
*/
const sans = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
const sans = createGeistSansFont().family
const mono = createGeistMonoFont().family
const base = getDefaultGuiConfig('web')
@@ -51,6 +62,7 @@ export const config = createGui({
fonts: {
body: { ...base.fonts.body, family: sans },
heading: { ...base.fonts.heading, family: sans },
mono: { ...base.fonts.body, family: mono },
},
})
export default config
@@ -70,21 +82,48 @@ declare module '@hanzogui/core' {
}
/**
* Put the kit's stylesheet on the document without writing markup.
* The typeface, said once, to both sides of the seam.
*
* Called once, before the first render, so the first paint is styled. It
* reports whether the stylesheet went on: a browser with no constructable
* stylesheets renders unstyled, which is safe and obvious, rather than falling
* back to a <style> element the policy would refuse anyway.
* The kit dresses its own components from the config above. Everything the kit
* does not own — body, tables, ids, code — reads `--hz-font-sans` and
* `--hz-font-mono` from app.css. Both stacks come from the same two constants,
* so there is one answer to "what is this console set in" and no way for the
* two halves of the screen to disagree.
*
* At `:root`, which beats the floor app.css declares on `html` on specificity
* rather than on source order — so it does not matter where in the cascade an
* adopted stylesheet lands.
*/
const typeface = `:root{--hz-font-sans:${sans};--hz-font-mono:${mono}}`
/**
* Put the console's stylesheets on the document without writing markup.
*
* Called once, before the first render, so the first paint is styled and the
* faces are already being fetched. It reports whether they went on: a browser
* with no constructable stylesheets renders unstyled, which is safe and
* obvious, rather than falling back to a <style> element the policy would
* refuse anyway.
*
* Two sheets, because they answer to different owners: the faces and the two
* stacks are this console's, and the rest is whatever the kit's config
* generates. @font-face in a constructed stylesheet installs a real face — the
* rules are adopted on the Document, not on a shadow root, which is the case
* that never worked — and e2e/font.spec.ts proves the face actually loads
* rather than that the rule was merely written.
*
* The kit publishes a theme as `:root .t_dark` — a descendant selector — so a
* theme is always a subtree and never reaches :root. app.css bridges the theme
* to its own names on the app's roots for that reason; see the note there.
* to its own names on the app's roots for that reason; see the note there. A
* typeface is not a theme, which is why it is at :root here and not in that
* bridge.
*/
export function adoptStyles(doc: Document = document): boolean {
if (typeof CSSStyleSheet === 'undefined' || !('adoptedStyleSheets' in doc)) return false
const sheet = new CSSStyleSheet()
sheet.replaceSync(config.getCSS())
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet]
const type = new CSSStyleSheet()
type.replaceSync(sheet(origin(), [sans, mono]) + typeface)
const kit = new CSSStyleSheet()
kit.replaceSync(config.getCSS())
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, type, kit]
return true
}
+1
View File
@@ -14,6 +14,7 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
+81 -2
View File
@@ -1,5 +1,80 @@
import { defineConfig } from 'vite'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig, type Plugin } from 'vite'
import { cdn, dir, faces, url } from './src/font.ts'
/**
* Where this build fetches its typeface from.
*
* Unset — every build we ship — is our own CDN: one copy of Geist, one version,
* one cache, shared by every Hanzo property, and never a request to a font host
* that is not ours.
*
* `FONT_ORIGIN=` (set, empty) is the escape hatch, and it is a build argument
* rather than a fork: an on-prem install that cannot reach us serves the same
* two files, at the same paths, from its own origin. Same family names, same
* token names, same @font-face rules, same stylesheet — only the origin moves,
* and `font-src 'self'` already covers it.
*
* It is fixed at build because a face is needed for the first paint, and
* anything the console would have to ask for first arrives too late to set the
* first frame in.
*/
const origin = process.env.FONT_ORIGIN ?? cdn
/**
* The typeface, delivered.
*
* Two jobs, both of which have to agree with src/font.ts about where the files
* are, which is why they read the paths from it rather than restating them.
*/
function typeface(from: string): Plugin {
// The `geist` package publishes no subpath for its font files, so the package
// root is found through the one specifier it does export. The bytes served
// are that package's, at the version pinned in package.json — which is what
// makes the licence checkable: the OFL text below is the one shipped with the
// exact files that ship.
const root = dirname(dirname(createRequire(import.meta.url).resolve('geist/font')))
const file = (rel: string) => readFileSync(join(root, rel))
return {
name: 'typeface',
// The two files the first paint needs, asked for before the parser has
// reached the bundle. `crossorigin` is required even when the origin is
// this one: fonts are always fetched in CORS mode, and a preload whose mode
// disagrees with the fetch is a second download rather than a saved one.
transformIndexHtml: () =>
faces.map((f) => ({
tag: 'link',
attrs: {
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: url(from, f),
crossorigin: '',
},
injectTo: 'head' as const,
})),
// Self-hosted: the same two files at the same paths, emitted into the
// bundle so the app's own origin serves them, with the licence beside them.
// In the shipped configuration this emits nothing at all, so a console that
// uses the CDN carries no font bytes.
generateBundle() {
if (from !== '') return
const at = dir.slice(1)
for (const f of faces) {
this.emitFile({ type: 'asset', fileName: `${at}/${f.file}`, source: file(`dist/fonts/${f.cut}/${f.from}`) })
}
this.emitFile({ type: 'asset', fileName: `${at}/OFL.txt`, source: file('LICENSE.txt') })
},
}
}
// The console is served from the root of its own host (aml.hanzo.ai) by
// hanzoai/static in SPA mode, so every deep link — /cases/abc, /callback —
@@ -7,8 +82,12 @@ import react from '@vitejs/plugin-react'
// under a path prefix: the API lives on api.<brand>/v1/aml and this bundle
// lives on its own host, and neither is inside the other.
export default defineConfig({
plugins: [react()],
plugins: [react(), typeface(origin)],
base: '/',
// The origin the @font-face rules point at, resolved once here and read by
// src/font.ts. A constant rather than an environment lookup, so the bundle
// carries the answer and not the question.
define: { __FONT_ORIGIN__: JSON.stringify(origin) },
// @hanzo/gui is one kit for web and native, so its default configuration
// reaches for the react-native animation driver even on this side. The kit
// ships the web stand-in for exactly that; pointing the two react-native