Compare commits

..
2 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 b34562333b Show the crash overlay when the Go loader fails to load
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 02:05:39 -04:00
ZacharyZhang-NYandClaude Opus 5.5 127f53d5f5 Add web shell, build and deploy scripts, Playwright smoke
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 01:59:44 -04:00
14 changed files with 1971 additions and 15 deletions

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ docs/prd/
docs/acceptance.md
/gallery.exe
/web/dist/
/web/.wrangler/
/web/node_modules/
/web/test-results/
/web/playwright-report/
+3 -2
View File
@@ -27,7 +27,7 @@ kit/ helpers shared by three or more components
<category>/ one component per file, package = folder name
gallery/ registry, demos (demos_<category>*.go), app view, tests
cmd/gallery/ one main for desktop and browser; hook_js.go for tests
web/ shell, build.sh, deploy.sh, wrangler.jsonc, _headers, Playwright tests
web/ shell, build.mjs, deploy.mjs, wrangler.jsonc, _headers, Playwright tests
internal/archtest/ import and file rules
```
@@ -86,7 +86,8 @@ code panel shows the demo function's own source, cut from the embedded `demos*.g
## Deploy
- `npm run deploy` in `web/` (clean tree only): live page beside new files first, then the new page tested on its
preview URL, promoted, and tested live at https://crychicui.zhangyanghaha0407.workers.dev.
preview URL, promoted, and tested live at https://crychicui.zhangyanghaha0407.workers.dev. The Worker's first
deploy is `npm run deploy -- --first`.
- Rollback: `npx wrangler rollback <version-id>` in `web/`.
## Review
+2
View File
@@ -50,6 +50,7 @@ func (a *App) View(c *ui.Context) {
}
ui.Column(c).Fill().Children(func() {
a.topBar(c)
ui.Divider(c)
ui.Row(c).Grow(1).AlignItems(ui.Stretch).Children(func() {
a.sidebar(c)
ui.Column(c).Grow(1).Children(func() {
@@ -60,6 +61,7 @@ func (a *App) View(c *ui.Context) {
a.pages(c)
})
})
ui.Divider(c)
a.statusBar(c)
})
a.mu.Lock()
+11 -13
View File
@@ -15,8 +15,7 @@ var modes = []string{"System", "Light", "Dark"}
func (a *App) topBar(c *ui.Context) {
t := c.Theme()
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(4)).AlignItems(ui.Center).
Border(1, t.Border).Children(func() {
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(4)).AlignItems(ui.Center).Children(func() {
ui.Text(c, "CrychicUI").Bold().FontSize(t.Rem(1.15))
ui.Spacer(c)
ui.SearchField(c, &a.query).Label("Search components").Width(240)
@@ -56,17 +55,16 @@ func (a *App) sidebar(c *ui.Context) {
func (a *App) statusBar(c *ui.Context) {
t := c.Theme()
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(1.5), t.Space(4)).Border(1, t.Border).
Children(func() {
kit.Muted(c, "Log")
ui.Text(c, strings.Join(a.events, " · ")).MaxLines(1).Grow(1).Shrink(1)
kit.Muted(c, "Cues")
var cues []string
for _, q := range recentCues(8) {
cues = append(cues, q.String())
}
ui.Text(c, strings.Join(cues, " ")).MaxLines(1)
})
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(1.5), t.Space(4)).Children(func() {
kit.Muted(c, "Log")
ui.Text(c, strings.Join(a.events, " · ")).MaxLines(1).Grow(1).Shrink(1)
kit.Muted(c, "Cues")
var cues []string
for _, q := range recentCues(8) {
cues = append(cues, q.String())
}
ui.Text(c, strings.Join(cues, " ")).MaxLines(1)
})
}
func recentCues(n int) []sound.Cue {
+6
View File
@@ -0,0 +1,6 @@
/a/*
Cache-Control: public, max-age=31536000, immutable
/
Cache-Control: no-cache
/index.html
Cache-Control: no-cache
+36
View File
@@ -0,0 +1,36 @@
// Builds web/dist; files under a/ are named by content.
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const web = dirname(fileURLToPath(import.meta.url))
const root = dirname(web)
const dist = join(web, 'dist')
rmSync(dist, { recursive: true, force: true })
mkdirSync(join(dist, 'a'), { recursive: true })
execFileSync('go', ['build', '-trimpath', '-ldflags=-s -w', '-tags', 'mygo_noinspector', '-o',
join(dist, 'app.wasm'), './cmd/gallery'], { cwd: root, stdio: 'inherit', env: { ...process.env, GOOS: 'js', GOARCH: 'wasm' } })
const goroot = execFileSync('go', ['env', 'GOROOT'], { encoding: 'utf8' }).trim()
copyFileSync(join(goroot, 'lib', 'wasm', 'wasm_exec.js'), join(dist, 'wasm_exec.js'))
// name moves a file into a/ by its content hash.
function name (base, file, ext) {
const hash = createHash('sha256').update(readFileSync(join(dist, file))).digest('hex').slice(0, 12)
const to = `a/${base}.${hash}.${ext}`
renameSync(join(dist, file), join(dist, to))
return to
}
const size = statSync(join(dist, 'app.wasm')).size
const wasm = name('app', 'app.wasm', 'wasm')
const exec = name('wasm_exec', 'wasm_exec.js', 'js')
writeFileSync(join(dist, 'main.js'),
readFileSync(join(web, 'main.js'), 'utf8').replaceAll('__WASM__', wasm).replaceAll('__SIZE__', String(size)))
const main = name('main', 'main.js', 'js')
writeFileSync(join(dist, 'index.html'),
readFileSync(join(web, 'index.html'), 'utf8').replace('__EXEC__', exec).replace('__MAIN__', main))
copyFileSync(join(web, '_headers'), join(dist, '_headers'))
console.log(`web/dist built: ${main} ${wasm} (${size} bytes)`)
+84
View File
@@ -0,0 +1,84 @@
// Ships files first, then the page, so none goes missing.
import { execSync } from 'node:child_process'
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const web = dirname(fileURLToPath(import.meta.url))
const site = 'https://crychicui.zhangyanghaha0407.workers.dev'
// --first deploys a Worker that has no live page yet.
const first = process.argv.includes('--first')
function fail (msg) {
console.error('deploy: ' + msg)
process.exit(1)
}
const run = (cmd, env = {}) => execSync(cmd, { cwd: web, stdio: 'inherit', env: { ...process.env, ...env } })
const out = cmd => execSync(cmd, { cwd: web, encoding: 'utf8' })
// patient retries check every 2 s for a minute.
async function patient (what, check) {
for (let i = 0; i < 30; i++) {
if (await check()) return
await new Promise(resolve => setTimeout(resolve, 2000))
}
fail('gave up waiting for ' + what)
}
async function get (url) {
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) fail(`${url}: HTTP ${res.status}`)
return Buffer.from(await res.arrayBuffer())
}
// upload makes a version from the assets in dir.
function upload (dir, message) {
const text = out(`npx wrangler versions upload --assets "${dir}" --message "${message}"`)
console.error(text)
const id = text.match(/^Worker Version ID: (\S+)/m)?.[1]
const preview = text.match(/^Version Preview URL: (\S+)/m)?.[1]
if (!id || !preview) fail('wrangler printed no version id or preview URL')
return { id, preview }
}
const promote = (id, message) => run(`npx wrangler versions deploy "${id}@100%" --yes --message "${message}"`)
const live = async main => (await fetch(site + '/', { cache: 'no-store' }).then(r => r.text())).includes(main)
if (out('git status --porcelain').trim()) fail('commit first; each version names its commit')
run('node build.mjs')
const rev = out('git rev-parse --short HEAD').trim()
const mainOf = html => html.match(/src="(a\/main\.[0-9a-f]+\.js)"/)?.[1]
const main = mainOf(readFileSync(join(web, 'dist', 'index.html'), 'utf8'))
if (!main) fail('dist/index.html names no main script')
if (first) {
run(`npx wrangler deploy --message "${rev}"`)
} else {
// The live build's files ride along, for pages already open.
const old = (await get(site + '/')).toString()
const oldMain = mainOf(old)
if (!oldMain) fail('the live page names no main script')
for (const f of old.matchAll(/src="(a\/[^"]+)"/g)) writeFileSync(join(web, 'dist', f[1]), await get(`${site}/${f[1]}`))
const wasm = readFileSync(join(web, 'dist', oldMain), 'utf8').match(/fetch\('(a\/app\.[0-9a-f]+\.wasm)'\)/)?.[1]
if (!wasm) fail('the live main script names no wasm')
writeFileSync(join(web, 'dist', wasm), await get(`${site}/${wasm}`))
const stage = mkdtempSync(join(tmpdir(), 'crychic-'))
mkdirSync(join(stage, 'a'))
writeFileSync(join(stage, 'index.html'), old)
cpSync(join(web, 'dist', 'a'), join(stage, 'a'), { recursive: true })
cpSync(join(web, 'dist', '_headers'), join(stage, '_headers'))
// Step one: the live page beside the new files.
const files = upload(stage, `${rev} files`)
promote(files.id, `${rev} files`)
await patient('the new files to go live', async () => (await fetch(`${site}/${main}`, { cache: 'no-store' })).ok)
rmSync(stage, { recursive: true, force: true })
// Step two: the new page, tested on its preview first.
const page = upload(join(web, 'dist'), rev)
run('npx playwright test', { BASE_URL: page.preview })
promote(page.id, rev)
}
await patient('the new page to go live', () => live(main))
run('npx playwright test', { BASE_URL: site })
console.log(`deploy: ${rev} live at ${site}`)
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>CrychicUI</title>
<meta name="description" content="1500 MyGo native UI components with motion and sound, drawn by Go compiled to WebAssembly.">
<meta name="color-scheme" content="light dark">
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: light-dark(#ffffff, #18181b); }
#mygo { position: fixed; inset: 0; }
#loading { position: fixed; inset: 0; display: grid; place-items: center; font: 14px system-ui, sans-serif;
color: light-dark(#71717a, #a1a1aa); }
#track { width: 240px; height: 4px; margin-top: 12px; border-radius: 999px; background: light-dark(#f4f4f5, #27272a); }
#fill { width: 0; height: 100%; border-radius: 999px; background: #2563eb; transition: width 120ms ease-out; }
#over { position: fixed; inset: 0; display: grid; place-items: center; padding: 24px;
background: light-dark(#ffffff, #18181b); color: light-dark(#18181b, #f4f4f5); font: 14px system-ui, sans-serif; }
#over[hidden] { display: none; }
#over h1 { font-size: 20px; margin: 0 0 12px; }
#over pre { max-width: 80vw; max-height: 50vh; overflow: auto; white-space: pre-wrap; padding: 12px;
border: 1px solid light-dark(#d9d9de, #3f3f46); border-radius: 6px; font: 12px ui-monospace, monospace; }
#over button { font: inherit; padding: 6px 14px; border: 0; border-radius: 6px; background: #2563eb; color: #fff;
cursor: pointer; }
</style>
</head>
<body>
<div id="mygo"></div>
<div id="loading" role="progressbar" aria-label="Loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div>Loading CrychicUI<div id="track"><div id="fill"></div></div></div>
</div>
<div id="over" hidden role="alertdialog" aria-labelledby="over-title">
<div><h1 id="over-title">The gallery stopped</h1><pre id="why"></pre><button id="reload">Reload</button></div>
</div>
<noscript>This gallery runs Go compiled to WebAssembly. Turn on JavaScript to see it.</noscript>
<script src="__EXEC__"></script>
<script src="__MAIN__"></script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
// Streams the wasm, runs Go, shows crashes.
const stderr = []
function crash (msg) {
const why = [msg, stderr.join('').slice(-4000)].filter(Boolean).join('\n\n')
console.error('crychic: crash', why)
document.getElementById('why').textContent = why
document.getElementById('over').hidden = false
}
document.getElementById('reload').onclick = () => location.reload()
async function load () {
if (!globalThis.Go || !globalThis.fs) throw new Error('wasm_exec.js did not load.')
const write = globalThis.fs.writeSync.bind(globalThis.fs)
globalThis.fs.writeSync = (fd, buf) => {
if (fd === 2) stderr.push(new TextDecoder().decode(buf))
return write(fd, buf)
}
const t0 = performance.now()
const res = await fetch('__WASM__')
if (!res.ok) throw new Error(`${res.url}: HTTP ${res.status}`)
const total = Number('__SIZE__')
const reader = res.body.getReader()
const parts = []
let got = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
parts.push(value)
got += value.length
const pct = Math.min(100, Math.floor(got / total * 100))
document.getElementById('fill').style.width = pct + '%'
document.getElementById('loading').setAttribute('aria-valuenow', pct)
}
const bytes = new Uint8Array(got)
let off = 0
for (const p of parts) {
bytes.set(p, off)
off += p.length
}
const go = new Go()
const { instance } = await WebAssembly.instantiate(bytes, go.importObject)
go.run(instance).then(() => crash('The Go program exited.'), (err) => crash(String(err && err.stack || err)))
await new Promise((resolve, reject) => {
const until = performance.now() + 30000
const poll = () => {
if (window.__crychic?.ready) return resolve()
if (performance.now() > until) return reject(new Error('No frame within 30 s.'))
requestAnimationFrame(poll)
}
poll()
})
const first = Math.round(performance.now() - t0)
document.getElementById('loading').remove()
window.__crychicBoot = { wasm: '__WASM__', bytes: got, first }
console.info(`crychic: wasm ${(got / 1e6).toFixed(1)} MB, first frame ${first} ms`)
}
load().catch((err) => crash(String(err && err.stack || err)))
+1636
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "crychicui-web",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs",
"test": "node build.mjs && playwright test",
"deploy": "node deploy.mjs"
},
"devDependencies": {
"@playwright/test": "1.64.0",
"wrangler": "4.149.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test'
// BASE_URL runs the tests against a deployed site.
const url = process.env.BASE_URL
export default defineConfig({
testDir: 'tests',
timeout: 90000,
use: { baseURL: url || 'http://localhost:4173', viewport: { width: 1280, height: 820 } },
webServer: url
? undefined
: { command: 'python3 -m http.server 4173 --directory dist', url: 'http://localhost:4173', reuseExistingServer: true }
})
+60
View File
@@ -0,0 +1,60 @@
import { brotliCompressSync } from 'node:zlib'
import { test, expect } from '@playwright/test'
// pageErrors keeps each page's console and page errors.
const pageErrors = new WeakMap()
test.beforeEach(async ({ page }) => {
const seen = []
pageErrors.set(page, seen)
page.on('console', m => { if (m.type() === 'error') seen.push(m.text()) })
page.on('pageerror', e => seen.push(e.message))
})
// Every page ends free of errors, unless it expects them.
test.afterEach(async ({ page }) => {
if (test.info().annotations.some(a => a.type === 'expects errors')) return
expect(pageErrors.get(page)).toEqual([])
})
test('a missing loader shows the crash overlay', async ({ page }) => {
test.info().annotations.push({ type: 'expects errors' })
await page.route('**/a/wasm_exec.*.js', route => route.abort())
await page.goto('/')
await expect(page.locator('#over')).toBeVisible()
await expect(page.locator('#why')).toContainText('wasm_exec.js did not load.')
})
async function open (page, hash = '') {
await page.goto('/' + hash)
await page.waitForFunction(() => window.__crychicBoot)
}
const where = page => page.evaluate(() => window.__crychic.page())
test('boots, reports, and logs its budget', async ({ page, request }) => {
await open(page)
expect(await where(page)).toBe('/')
const stats = await page.evaluate(() => window.__crychic.stats())
expect(stats.categories).toBe(38)
expect(Array.isArray(await page.evaluate(() => window.__crychic.recent()))).toBe(true)
expect(await page.locator('#mygo canvas').count()).toBeGreaterThan(0)
const boot = await page.evaluate(() => window.__crychicBoot)
const wasm = await (await request.get('/' + boot.wasm)).body()
const br = brotliCompressSync(wasm).length
console.log(`budget: wasm ${(wasm.length / 1e6).toFixed(1)} MB, brotli ${(br / 1e6).toFixed(1)} MB, ` +
`first frame ${boot.first} ms, ${stats.components} components`)
})
test('deep link opens a category', async ({ page }) => {
await open(page, '#/c/layout')
expect(await where(page)).toBe('/c/layout')
})
test('the hash and history drive the router', async ({ page }) => {
await open(page)
await page.evaluate(() => { location.hash = '#/c/chart' })
await page.waitForFunction(() => window.__crychic.page() === '/c/chart')
await page.goBack()
await page.waitForFunction(() => window.__crychic.page() === '/')
})
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "crychicui",
"compatibility_date": "2026-10-01",
"workers_dev": true,
"preview_urls": true,
"assets": { "directory": "./dist" }
}