Skip to content

Commit 0eb12f4

Browse files
committed
feat(hub-ui-onboard): short-circuit when installed, dim the pill, add hide for now
When every named package is already in node_modules, onInstalled runs on the first request and the button never shows. The pill is smaller and sits at 25% opacity until hovered or open. The panel offers "Hide for now" for the browser tab next to "Disable entirely".
1 parent 6013aa3 commit 0eb12f4

12 files changed

Lines changed: 119 additions & 35 deletions

File tree

‎docs/content/1.guide/18.hub-initiate.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ if (!onboarding.disabled)
8585
injectScript(onboarding.scriptSrc)
8686
```
8787

88-
Install runs the project's package manager (detected from the lockfile) with the configured `packages` only; the request body controls nothing, and a cross-origin `POST` is refused. The button polls `status` once a second and, when `onInstalled` returned a handler, loads the real `embedded.js` and removes itself. Without a handler it asks the user to restart. Disable writes `<stateDir>/hub-ui-onboard.json` (default `node_modules/.devframe`); the host reads `onboarding.disabled` on the next start and injects nothing. Mount the onboarding only when the user did not set the host's own devtools option, so an explicit `devtools: true` or `false` in the host configuration always wins. [`examples/hub-onboard-vite`](https://github.com/devframes/devframe/tree/main/examples/hub-onboard-vite) shows the whole flow on Vite.
88+
Install runs the project's package manager (detected from the lockfile) with the configured `packages` only; the request body controls nothing, and a cross-origin `POST` is refused. The button polls `status` once a second and, when `onInstalled` returned a handler, loads the real `embedded.js` and removes itself. Without a handler it asks the user to restart. When every named package is already in `node_modules`, `onboarding.installed` is `true`, `onInstalled` runs on the first request, and the button never shows. The panel has two ways out: "Hide for now" hides the button for the browser tab (`sessionStorage`), and "Disable entirely" writes `<stateDir>/hub-ui-onboard.json` (default `node_modules/.devframe`) so the host reads `onboarding.disabled` on the next start and injects nothing. Mount the onboarding only when the user did not set the host's own devtools option, so an explicit `devtools: true` or `false` in the host configuration always wins. [`examples/hub-onboard-vite`](https://github.com/devframes/devframe/tree/main/examples/hub-onboard-vite) shows the whole flow on Vite.
8989

9090
## Renderer modules
9191

‎docs/content/6.errors/DF9003.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
---
22
title: 'DF9003: onInstalled Threw'
3-
description: '`onInstalled` threw after the packages were installed.'
3+
description: '`onInstalled` threw after the packages were installed: {reason}'
44
---
55

66
## Message
77

8-
> `onInstalled` threw after the packages were installed.
8+
> `onInstalled` threw after the packages were installed: `{reason}`
99
1010
## Cause
1111

12-
The packages are installed. The host's `onInstalled` callback, which usually imports the new packages and starts the hub, threw. The original error is attached as `cause`.
12+
The packages are installed. The host's `onInstalled` callback, which usually imports the new packages and starts the hub, threw. `{reason}` is its message; the original error is attached as `cause`.
1313

1414
## Fix
1515

‎examples/hub-onboard-vite/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@ pnpm --filter hub-onboard-vite dev
99
Open the printed URL. The button at the bottom left opens a panel with two actions:
1010

1111
- Install runs `pnpm add -D @devframes/hub @devframes/hub-ui @devframes/plugin-git` in this directory. When it finishes, the host starts the real hub on `/__devframes/` in the same process and the button swaps itself for the floating dock.
12-
- Disable writes `node_modules/.devframe/hub-ui-onboard.json`. The host reads `onboarding.disabled` on the next start and injects no script.
12+
- Hide for now hides the button for this browser tab.
13+
- Disable entirely writes `node_modules/.devframe/hub-ui-onboard.json`. The host reads `onboarding.disabled` on the next start and injects no script.
14+
15+
When the three packages are already installed, the button never shows: the host's `onInstalled` runs on the first request and the real dock appears directly.
1316

1417
Install changes this example's `package.json` and the lockfile. Run `git checkout -- examples/hub-onboard-vite pnpm-lock.yaml` and delete the state file to reset the demo.
1518

1619
## How it works
1720

1821
[`vite.config.ts`](./vite.config.ts) holds the whole integration:
1922

20-
- `createOnboarding({ packages, branding, onInstalled })` returns `nodeMiddleware`, `scriptSrc` and `disabled`.
23+
- `createOnboarding({ packages, branding, onInstalled })` returns `nodeMiddleware`, `scriptSrc`, `disabled` and `installed`.
2124
- `server.middlewares.use(onboarding.nodeMiddleware)` serves `/__devframes/embedded.js` and the `/__devframes/__onboard/*` routes.
2225
- `transformIndexHtml` injects `<script type="module" src="/__devframes/embedded.js">` unless the user disabled it earlier.
2326
- `onInstalled` resolves the new packages from the project root, calls `initHub` with Vite's HTTP server, and returns `hub.handler`. From then on every request under the base goes to the hub.

‎examples/hub-onboard-vite/index.html‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ <h1>Hub onboarding (Vite)</h1>
1212
<li>is <code>/__devframes/embedded.js</code>, a 20 kB stand-in for the hub UI</li>
1313
<li>installs <code>@devframes/hub</code>, <code>@devframes/hub-ui</code> and <code>@devframes/plugin-git</code> when you click Install</li>
1414
<li>then hands <code>/__devframes/</code> to the real hub in this same process, no restart</li>
15-
<li>writes <code>node_modules/.devframe/hub-ui-onboard.json</code> when you click Disable</li>
15+
<li>hides for this tab when you click Hide for now</li>
16+
<li>writes <code>node_modules/.devframe/hub-ui-onboard.json</code> when you click Disable entirely</li>
1617
</ul>
1718
</body>
1819
</html>

‎packages/hub-ui-onboard/src/client/index.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { DEVFRAME_LOGO } from './logo'
1313
const moduleUrl = import.meta.url
1414
const base = new URL('./', moduleUrl)
1515
const POLL_MS = 1000
16+
const HIDDEN_KEY = 'devframes-onboard-hidden'
1617

1718
const api = (path: string, init?: RequestInit) => fetch(new URL(`__onboard/${path}`, base), init)
1819

@@ -86,9 +87,10 @@ function mount(initial: OnboardingStatus): void {
8687
pill.className = 'devframes-onboard-pill'
8788
pill.title = messages.title
8889
pill.setAttribute('aria-haspopup', 'dialog')
90+
pill.setAttribute('aria-expanded', 'false')
8991
const glow = document.createElement('span')
9092
glow.className = 'devframes-onboard-glow'
91-
pill.append(glow, mark(branding, 'w-5 h-5'))
93+
pill.append(glow, mark(branding, 'w-4 h-4'))
9294

9395
const panel = document.createElement('div')
9496
panel.className = 'devframes-onboard-panel'
@@ -113,11 +115,18 @@ function mount(initial: OnboardingStatus): void {
113115
note.className = 'm-0 text-xs'
114116
note.hidden = true
115117

116-
const install = button('btn-primary', messages.install, () => void startInstall())
117-
const disable = button('btn-action', messages.disable, () => void disableDevtools())
118+
const install = button('btn-primary text-sm justify-center', messages.install, () => void startInstall())
119+
const hide = button('btn-text text-sm color-muted', messages.hide, () => {
120+
sessionStorage.setItem(HIDDEN_KEY, '1')
121+
host.remove()
122+
})
123+
const disable = button('btn-text text-sm color-muted', messages.disable, () => void disableDevtools())
124+
const secondary = document.createElement('div')
125+
secondary.className = 'flex items-center gap-1 -ml-2'
126+
secondary.append(hide, disable)
118127
const actions = document.createElement('div')
119-
actions.className = 'flex items-center justify-end gap-2'
120-
actions.append(disable, install)
128+
actions.className = 'flex flex-col gap-1'
129+
actions.append(install, secondary)
121130

122131
panel.append(heading, description, command, note, actions)
123132
scheme.append(pill, panel)
@@ -188,8 +197,11 @@ async function main(): Promise<void> {
188197
// Never stack a second button inside an iframe of the same origin.
189198
if (window.parent !== window)
190199
return
200+
if (sessionStorage.getItem(HIDDEN_KEY))
201+
return
191202
const status = await fetchStatus()
192-
if (status.state === 'disabled')
203+
// Nothing to offer: disabled by the user, or already installed with no hub to hand off to.
204+
if (status.state === 'disabled' || status.state === 'installed')
193205
return
194206
if (status.state === 'ready') {
195207
await handOff()

‎packages/hub-ui-onboard/src/client/style.css‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44
* clearly next to the utilities.
55
*/
66
.devframes-onboard-pill {
7-
--uno: fixed bottom-4 left-4 z-floating-anchor w-10 h-10 rounded-full border border-base bg-dock-glass shadow;
7+
--uno: fixed bottom-4 left-4 z-floating-anchor w-8 h-8 rounded-full border border-base bg-dock-glass shadow;
88
--uno: flex items-center justify-center cursor-pointer select-none p-0 outline-none;
9-
--uno: transition-transform duration-200 hover:scale-105 focus-visible:ring-2 ring-primary-400;
9+
--uno: op25 hover:op100 focus-visible:op100 focus-visible:ring-2 ring-primary-400;
10+
--uno: transition-all duration-200 hover:scale-105;
11+
}
12+
13+
/* Full opacity while the panel is open, so the pill reads as its anchor. */
14+
.devframes-onboard-pill[aria-expanded='true'] {
15+
--uno: op100;
1016
}
1117

1218
.devframes-onboard-glow {

‎packages/hub-ui-onboard/src/diagnostics.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const diagnostics = defineDiagnostics({
2222
fix: 'Make sure that `cwd` points at the project that should receive the dependency (in a workspace, the package that runs the dev server).',
2323
},
2424
DF9003: {
25-
why: '`onInstalled` threw after the packages were installed.',
25+
why: (p: { reason: string }) => `\`onInstalled\` threw after the packages were installed: ${p.reason}`,
2626
fix: 'The packages are installed. Fix the error in your `onInstalled` callback, or restart the dev server to load them.',
2727
},
2828
DF9004: {

‎packages/hub-ui-onboard/src/index.ts‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { pipeline } from 'node:stream/promises'
1111
import { fileURLToPath } from 'node:url'
1212
import { Diagnostic } from 'nostics'
1313
import { diagnostics } from './diagnostics'
14-
import { formatCommand, resolveInstallCommand, runInstall } from './install'
14+
import { formatCommand, packagesInstalled, resolveInstallCommand, runInstall } from './install'
1515

1616
export type * from './types'
1717

@@ -38,7 +38,8 @@ function defaultMessages(productName: string): OnboardingMessages {
3838
title: productName,
3939
description: `${productName} is not installed in this project yet. Install it to open the panel.`,
4040
install: `Install ${productName}`,
41-
disable: 'Disable',
41+
hide: 'Hide for now',
42+
disable: 'Disable entirely',
4243
installing: 'Installing...',
4344
restart: `Installed. Restart your dev server to open ${productName}.`,
4445
retry: 'Retry',
@@ -120,7 +121,8 @@ export function createOnboarding(options: CreateOnboardingOptions): Onboarding {
120121
const messages = { ...defaultMessages(branding.productName?.trim() || 'Devframes'), ...options.messages }
121122

122123
const disabled = readDisabled(stateFile)
123-
let state: OnboardingState = disabled ? 'disabled' : 'idle'
124+
const installed = !disabled && packagesInstalled(plan)
125+
let state: OnboardingState = disabled ? 'disabled' : installed ? 'installed' : 'idle'
124126
let error: OnboardingStatus['error']
125127
let delegate: OnboardingHandler | undefined
126128
let command: Promise<ResolvedCommand> | undefined
@@ -132,6 +134,18 @@ export function createOnboarding(options: CreateOnboardingOptions): Onboarding {
132134
state = 'error'
133135
}
134136

137+
/** Hand the base to the host's hub, if it offers one. */
138+
async function activate(): Promise<void> {
139+
try {
140+
const next = await options.onInstalled?.()
141+
delegate = typeof next === 'function' ? next : undefined
142+
state = delegate ? 'ready' : 'installed'
143+
}
144+
catch (cause) {
145+
fail(cause, () => diagnostics.DF9003({ reason: cause instanceof Error ? cause.message : String(cause), cause }))
146+
}
147+
}
148+
135149
async function install(): Promise<void> {
136150
state = 'installing'
137151
error = undefined
@@ -145,16 +159,13 @@ export function createOnboarding(options: CreateOnboardingOptions): Onboarding {
145159
fail(cause, () => diagnostics.DF9001({ command, exitCode: undefined, stderr: String(cause) }))
146160
return
147161
}
148-
try {
149-
const next = await options.onInstalled?.()
150-
delegate = typeof next === 'function' ? next : undefined
151-
state = delegate ? 'ready' : 'installed'
152-
}
153-
catch (cause) {
154-
fail(cause, () => diagnostics.DF9003({ cause }))
155-
}
162+
await activate()
156163
}
157164

165+
// Already installed: the hub takes over on the first request. Not earlier,
166+
// because the host's `onInstalled` may need a server that exists only later.
167+
let activated: Promise<void> | undefined
168+
158169
function disable(): Response {
159170
try {
160171
mkdirSync(join(stateFile, '..'), { recursive: true })
@@ -173,6 +184,8 @@ export function createOnboarding(options: CreateOnboardingOptions): Onboarding {
173184
}
174185

175186
const handler: OnboardingHandler = async (request) => {
187+
if (installed)
188+
await (activated ??= activate())
176189
if (delegate)
177190
return delegate(request)
178191
const { pathname } = new URL(request.url)
@@ -212,6 +225,7 @@ export function createOnboarding(options: CreateOnboardingOptions): Onboarding {
212225
.catch(cause => next ? next(cause) : res.destroy(cause))
213226
},
214227
disabled,
228+
installed,
215229
scriptSrc: `${base}embedded.js`,
216230
}
217231
}

‎packages/hub-ui-onboard/src/install.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ResolvedCommand } from 'package-manager-detector'
2-
import { access } from 'node:fs/promises'
2+
import { existsSync } from 'node:fs'
33
import { join } from 'node:path'
44
import process from 'node:process'
55
import { detect, resolveCommand } from 'package-manager-detector'
@@ -41,16 +41,30 @@ export async function runInstall(plan: InstallPlan, command: ResolvedCommand): P
4141
stderr: result.stderr.trim().slice(-2048),
4242
})
4343
}
44-
for (const spec of plan.packages) {
45-
const name = packageName(spec)
46-
if (!name)
47-
continue
48-
const installed = await access(join(plan.cwd, 'node_modules', name)).then(() => true, () => false)
49-
if (!installed)
44+
for (const name of namedPackages(plan)) {
45+
if (!isInstalled(plan.cwd, name))
5046
throw diagnostics.DF9002({ name, cwd: plan.cwd })
5147
}
5248
}
5349

50+
/**
51+
* `true` when every named package is already in `<cwd>/node_modules`, so the
52+
* onboarding has nothing to install. Path, URL and alias specs cannot be
53+
* checked by name, so a plan made only of those is never "already installed".
54+
*/
55+
export function packagesInstalled(plan: InstallPlan): boolean {
56+
const names = namedPackages(plan)
57+
return names.length > 0 && names.every(name => isInstalled(plan.cwd, name))
58+
}
59+
60+
function isInstalled(cwd: string, name: string): boolean {
61+
return existsSync(join(cwd, 'node_modules', name))
62+
}
63+
64+
function namedPackages(plan: InstallPlan): string[] {
65+
return plan.packages.map(packageName).filter((name): name is string => name !== undefined)
66+
}
67+
5468
/**
5569
* The package name of a bare spec (`foo`, `@scope/foo@^1`), or `undefined`
5670
* for a path, URL or alias spec whose installed name is not in the spec.

‎packages/hub-ui-onboard/src/types.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export interface OnboardingMessages {
1717
/** Short sentence under the heading. */
1818
description: string
1919
install: string
20+
/** Hides the button for this browser tab (`sessionStorage`). */
21+
hide: string
22+
/** Writes the state file; the host stops injecting the button. */
2023
disable: string
2124
installing: string
2225
/** Shown when the install finished and no hot swap happened. */
@@ -68,6 +71,12 @@ export interface Onboarding {
6871
nodeMiddleware: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: (err?: unknown) => void) => void
6972
/** `true` when the user disabled DevTools in an earlier session; skip injecting `scriptSrc`. */
7073
disabled: boolean
74+
/**
75+
* `true` when every named package was already in `node_modules` at
76+
* creation. `onInstalled` then runs on the first request, which already
77+
* reaches its handler; the button never shows.
78+
*/
79+
installed: boolean
7180
/** `<base>embedded.js`: the `<script type="module">` URL to inject. */
7281
scriptSrc: string
7382
}

0 commit comments

Comments
 (0)