Skip to content

Commit fac9a69

Browse files
committed
feat: improve hub-ui-onboard
1 parent 56cc927 commit fac9a69

13 files changed

Lines changed: 315 additions & 42 deletions

File tree

‎knip.jsonc‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
// one a standalone, runnable example, not imported by anything else.
3838
"entry": ["design/*.ts", "skills/devframe/templates/*.ts", "scripts/smoke-bun.ts"]
3939
},
40+
"packages/hub-ui-onboard": {
41+
// The playground is a dev-only Vite app (`pnpm play`); its config is
42+
// loaded by the Vite CLI, not imported.
43+
"entry": ["playground/vite.config.ts"]
44+
},
4045
"docs": {
4146
// Nuxt auto-registers `app/components` and `app/composables`; the files
4247
// here shadow same-named pieces of the comark-docs layer (AppHeaderBrand,

‎packages/hub-ui-onboard/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"build:css": "tsx scripts/build-css.ts",
3333
"typecheck": "tsc --noEmit",
3434
"test": "vitest run",
35-
"prepack": "turbo run build --filter=@devframes/hub-ui-onboard"
35+
"prepack": "turbo run build --filter=@devframes/hub-ui-onboard",
36+
"play": "pnpm run build:css && pnpm -C playground play"
3637
},
3738
"dependencies": {
3839
"nostics": "catalog:deps",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<meta name="color-scheme" content="light dark">
7+
<title>hub-ui-onboard playground</title>
8+
<style>
9+
body { font: 14px/1.5 system-ui, sans-serif; max-width: 36rem; margin: 3rem auto; padding: 0 1rem; }
10+
fieldset { margin: 0 0 1rem; border: 1px solid #8884; border-radius: 6px; }
11+
button { font: inherit; margin: 0 0.25rem 0.25rem 0; }
12+
code { font-family: ui-monospace, monospace; }
13+
</style>
14+
</head>
15+
<body>
16+
<h1>hub-ui-onboard</h1>
17+
<p>The floating button loads from <code>src/client</code> and talks to a mock <code>__onboard</code> API.</p>
18+
<fieldset>
19+
<legend>State</legend>
20+
<button data-state="idle">idle</button>
21+
<button data-state="installing">installing</button>
22+
<button data-state="installed">installed</button>
23+
<button data-state="error">error</button>
24+
<button data-state="idle" data-outcome="error">idle (install fails)</button>
25+
</fieldset>
26+
<fieldset>
27+
<legend>Theme</legend>
28+
<button data-theme="light">light</button>
29+
<button data-theme="dark">dark</button>
30+
<button data-theme="">system</button>
31+
</fieldset>
32+
<p>Each choice reloads the page. Hide state lives in <code>sessionStorage</code> and resets on reload here.</p>
33+
<script type="module">
34+
document.addEventListener('click', async (event) => {
35+
const el = event.target.closest('button')
36+
if (!el)
37+
return
38+
sessionStorage.clear()
39+
if ('theme' in el.dataset) {
40+
if (el.dataset.theme)
41+
localStorage.setItem('devframes-color-scheme', el.dataset.theme)
42+
else
43+
localStorage.removeItem('devframes-color-scheme')
44+
}
45+
else {
46+
await fetch(`/__mock/state?state=${el.dataset.state}&outcome=${el.dataset.outcome ?? ''}`)
47+
}
48+
location.reload()
49+
})
50+
</script>
51+
<script type="module" src="/src/client/index.ts"></script>
52+
</body>
53+
</html>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"private": true,
3+
"scripts": {
4+
"play": "vite dev"
5+
}
6+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { Plugin } from 'vite'
2+
import type { OnboardingState, OnboardingStatus } from '../src/types'
3+
import { spawn } from 'node:child_process'
4+
import { fileURLToPath } from 'node:url'
5+
import { defineConfig } from 'vite'
6+
import { defaultMessages } from '../src/index'
7+
8+
const root = fileURLToPath(new URL('..', import.meta.url))
9+
const productName = 'Devframes'
10+
11+
/**
12+
* A fake of the `__onboard/*` routes `createOnboarding` serves. The client
13+
* resolves them next to its own module (`/src/client/`). The playground page
14+
* picks the state through `/__mock/state`; Install runs a timer instead of a
15+
* package manager and ends in `installed` or `error`.
16+
*/
17+
function mockOnboarding(): Plugin {
18+
let state: OnboardingState = 'idle'
19+
let outcome: 'installed' | 'error' = 'installed'
20+
let timer: ReturnType<typeof setTimeout> | undefined
21+
22+
const status = (): OnboardingStatus => ({
23+
state,
24+
command: ['pnpm', 'add', '-D', '@devframes/hub', '@devframes/hub-ui'],
25+
branding: { productName, primaryColor: '#646cff' },
26+
messages: defaultMessages(productName),
27+
error: state === 'error' ? { code: 'DF9001', message: 'Mock install failed.' } : undefined,
28+
})
29+
30+
return {
31+
name: 'mock-onboarding',
32+
configureServer(server) {
33+
server.middlewares.use((req, res, next) => {
34+
const url = new URL(req.url ?? '/', 'http://localhost')
35+
const send = (body: unknown) => {
36+
res.setHeader('Content-Type', 'application/json')
37+
res.setHeader('Cache-Control', 'no-store')
38+
res.end(JSON.stringify(body))
39+
}
40+
if (url.pathname === '/__mock/state') {
41+
clearTimeout(timer)
42+
state = (url.searchParams.get('state') ?? 'idle') as OnboardingState
43+
outcome = url.searchParams.get('outcome') === 'error' ? 'error' : 'installed'
44+
return send({ state, outcome })
45+
}
46+
if (!url.pathname.startsWith('/src/client/__onboard/'))
47+
return next()
48+
const route = url.pathname.split('/').pop()
49+
if (route === 'install') {
50+
state = 'installing'
51+
timer = setTimeout(() => (state = outcome), 2500)
52+
}
53+
else if (route === 'disable') {
54+
state = 'disabled'
55+
}
56+
send(route === 'status' ? status() : { ok: true })
57+
})
58+
},
59+
}
60+
}
61+
62+
/**
63+
* The button's styles are compiled ahead of time into `.generated/css.ts`.
64+
* When a source file, `style.css` or a UnoCSS config changes, run the same
65+
* script `pnpm build:css` runs, then reload. The client is a self-running
66+
* module, so HMR would mount a second button: every change is a full reload.
67+
*/
68+
function rebuildCss(): Plugin {
69+
const watched = [
70+
fileURLToPath(new URL('../src/client/', import.meta.url)),
71+
fileURLToPath(new URL('../uno.config.ts', import.meta.url)),
72+
fileURLToPath(new URL('../../../design/', import.meta.url)),
73+
]
74+
const generated = fileURLToPath(new URL('../src/client/.generated/', import.meta.url))
75+
let timer: ReturnType<typeof setTimeout> | undefined
76+
77+
return {
78+
name: 'rebuild-onboard-css',
79+
configureServer(server) {
80+
server.watcher.add(watched)
81+
server.watcher.on('change', (file) => {
82+
if (file.startsWith(generated) || !watched.some(path => file.startsWith(path)))
83+
return
84+
clearTimeout(timer)
85+
timer = setTimeout(() => {
86+
const child = spawn('pnpm', ['run', 'build:css'], { cwd: root, stdio: 'inherit' })
87+
child.on('close', (code) => {
88+
if (code === 0)
89+
server.ws.send({ type: 'full-reload' })
90+
})
91+
}, 100)
92+
})
93+
},
94+
handleHotUpdate: ({ file }) => watched.some(path => file.startsWith(path)) ? [] : undefined,
95+
}
96+
}
97+
98+
export default defineConfig({
99+
root,
100+
server: { open: '/playground/' },
101+
plugins: [mockOnboarding(), rebuildCss()],
102+
})

0 commit comments

Comments
 (0)