Compare commits
2
Commits
ff50f9b35a
...
b34562333b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34562333b
|
||
|
|
127f53d5f5
|
No files matched your search
@@ -7,6 +7,7 @@ docs/prd/
|
||||
docs/acceptance.md
|
||||
/gallery.exe
|
||||
/web/dist/
|
||||
/web/.wrangler/
|
||||
/web/node_modules/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/a/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
/
|
||||
Cache-Control: no-cache
|
||||
/index.html
|
||||
Cache-Control: no-cache
|
||||
@@ -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)`)
|
||||
@@ -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}`)
|
||||
@@ -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
@@ -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)))
|
||||
Generated
+1636
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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() === '/')
|
||||
})
|
||||
@@ -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" }
|
||||
}
|
||||
Reference in new issue
Block a user