mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
merge: origin/main (the console's typeface)
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
*.db-shm
|
||||
.env
|
||||
ui/dist/
|
||||
ui/dist-offline/
|
||||
ui/node_modules/
|
||||
ui/tsconfig.tsbuildinfo
|
||||
ui/test-results/
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Generated
+888
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
|
||||
+81
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user