Skip to content

Commit 6b7ac9b

Browse files
committed
feat(hub-ui): embedded dock visibility modes (normal/passive/hidden)
Ports vitejs/devtools#465 (passive/hidden overlay) to the reference UI, but as one config field instead of the upstream three-client-entries approach. `createUi({ embeddedVisibility })` publishes `ConnectionMeta.configs.ui.embeddedVisibility`: - `normal` (default) — the floating dock shows immediately. - `passive` — starts hidden with a console hint; Shift+Alt+D reveals it, and the reveal persists per-origin so later sessions start shown. - `hidden` — starts hidden; Shift+Alt+D reveals it for the session only. Like the float/edge dock mode, it seeds a user-overridable preference: the config sets the default, the visitor's own reveal/hide wins from then on. The "Hide" command now conceals (toggleable back via the shortcut) rather than tearing the dock down for the session. Built with the help of an agent.
1 parent 5c51870 commit 6b7ac9b

7 files changed

Lines changed: 199 additions & 20 deletions

File tree

‎docs/guide/hub-initiate.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,19 @@ interface DevframeHubUi {
6161
}
6262
```
6363

64-
`@devframes/hub-ui`'s `createUi()` is the reference implementation: a standalone viewer plus the floating dock — one `<script type="module" src="/__devframes/embedded.js">` tag in the host page and the dock mounts itself, always visible. A viewer product supplies a different object to the same slot and reuses all the infrastructure; visibility policy (keyboard summon, passive modes) belongs entirely to the entry's author.
64+
`@devframes/hub-ui`'s `createUi()` is the reference implementation: a standalone viewer plus the floating dock — one `<script type="module" src="/__devframes/embedded.js">` tag in the host page and the dock mounts itself. A viewer product supplies a different object to the same slot and reuses all the infrastructure.
65+
66+
`createUi()` takes a few options: `branding` (rebrand the reference UI — logo, product name, primary color) and `embeddedVisibility` for the floating dock's reveal policy:
67+
68+
```ts
69+
createUi({ embeddedVisibility: 'passive' })
70+
```
71+
72+
- `'normal'` (default) — the dock is shown immediately.
73+
- `'passive'` — the dock starts hidden with a console hint; `Shift+Alt+D` reveals it, and the reveal persists per-origin so later sessions start shown.
74+
- `'hidden'` — the dock starts hidden; `Shift+Alt+D` reveals it for the current session only.
75+
76+
Both ride `ConnectionMeta.configs.ui` to the client. Like the float/edge dock mode, `embeddedVisibility` seeds a user-overridable preference — the visitor's own reveal/hide wins from then on.
6577

6678
## Renderer modules
6779

‎packages/hub-ui/src/client/constants.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { DevframeViewBuiltin } from '@devframes/hub'
22

33
/**
4-
* `window` event the "Hide" command dispatches to ask whoever mounted the
5-
* embedded dock to tear it down for the session. The hub-ui dock is always
6-
* visible by design — hiding is a page-lifetime action, and a reload brings
7-
* the dock back (see `src/client/embedded/index.ts`).
4+
* `window` event the "Hide" command dispatches to conceal the embedded dock.
5+
* The embedded bootstrap's visibility controller catches it and detaches the
6+
* dock; the `Shift+Alt+D` reveal shortcut (or, in `passive` mode, a later
7+
* reload) brings it back (see `src/client/embedded/visibility.ts`).
88
*/
99
export const HUB_UI_HIDE_EVENT = 'devframes:hub-ui:hide'
1010

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import type { DockPanelStorage } from '@devframes/hub/client'
22
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
33
import { useLocalStorage } from '@vueuse/core'
4-
import { HUB_UI_HIDE_EVENT } from '../constants'
54
import { DEFAULT_DOCK_PANEL_STORE } from '../state/docks'
5+
import { setupEmbeddedVisibility } from './visibility'
66

77
/**
88
* The floating-dock bootstrap the hub serves at `<base>embedded.js` — load
99
* it with one `<script type="module" src="<base>embedded.js">` tag and the
10-
* dock mounts immediately. Always visible by design: visibility policy
11-
* belongs to whoever authors an embedded entry, and this one's policy is
12-
* "you asked for devtools, here they are". The "Hide" command removes the
13-
* dock for the session; a reload brings it back.
10+
* dock mounts itself. Its reveal policy is `ConnectionMeta.configs.ui.embeddedVisibility`
11+
* (`normal` / `passive` / `hidden`): `normal` shows immediately, the others
12+
* start hidden and reveal with `Shift+Alt+D`. The "Hide" command conceals the
13+
* dock; the shortcut (or, for `passive`, a later reload) brings it back.
1414
*/
1515
let dockEl: HTMLElement | undefined
1616

@@ -77,12 +77,20 @@ async function mountDock(): Promise<void> {
7777
// inherits through the shadow tree. The embedded bootstrap never touches the
7878
// host page's <title>/favicon (it's a guest there).
7979
applyPrimaryColor(dockEl, branding.primaryColor)
80-
document.body.appendChild(dockEl)
81-
}
8280

83-
window.addEventListener(HUB_UI_HIDE_EVENT, () => {
84-
dockEl?.remove()
85-
dockEl = undefined
86-
})
81+
// Reveal policy: `normal` appends now; `passive`/`hidden` wait for the
82+
// Shift+Alt+D reveal (the element is built and ready, just detached).
83+
setupEmbeddedVisibility(
84+
rpc.connectionMeta.configs?.ui?.embeddedVisibility ?? 'normal',
85+
branding.productName,
86+
{
87+
show: () => {
88+
if (dockEl && !dockEl.isConnected)
89+
document.body.appendChild(dockEl)
90+
},
91+
hide: () => dockEl?.remove(),
92+
},
93+
)
94+
}
8795

8896
void mountDock()
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { HUB_UI_HIDE_EVENT } from '../constants'
2+
3+
/**
4+
* How the embedded floating dock reveals itself on a fresh page — the
5+
* reference UI's port of Nuxt DevTools' opt-in overlay, published as
6+
* `ConnectionMeta.configs.ui.embeddedVisibility` and set via
7+
* `createUi({ embeddedVisibility })`.
8+
*
9+
* - `normal` (default) — the dock is shown immediately.
10+
* - `passive` — the dock starts hidden and a console hint offers the reveal
11+
* shortcut; revealing persists per-origin, so later sessions on this
12+
* browser start shown. The "Hide" command returns to passive.
13+
* - `hidden` — the dock starts hidden and the shortcut reveals it for the
14+
* current session only; nothing is persisted.
15+
*
16+
* Whatever the policy, the reveal state is a user-overridable preference —
17+
* the same shape as the float/edge dock mode: the config seeds it, the
18+
* visitor's own reveal/hide wins from then on.
19+
*/
20+
export type EmbeddedVisibility = 'normal' | 'passive' | 'hidden'
21+
22+
/** Per-origin persisted reveal flag for `passive` mode. */
23+
const REVEAL_STORAGE_KEY = 'devframes-dock-revealed'
24+
25+
function readPersistedReveal(): boolean {
26+
try {
27+
return localStorage.getItem(REVEAL_STORAGE_KEY) === 'true'
28+
}
29+
catch {
30+
return false
31+
}
32+
}
33+
34+
function persistReveal(revealed: boolean): void {
35+
try {
36+
if (revealed)
37+
localStorage.setItem(REVEAL_STORAGE_KEY, 'true')
38+
else
39+
localStorage.removeItem(REVEAL_STORAGE_KEY)
40+
}
41+
catch {
42+
// Private-mode / disabled storage: reveal stays session-only, which is a
43+
// safe degradation — the dock is still reachable via the shortcut.
44+
}
45+
}
46+
47+
function isMac(): boolean {
48+
return typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent)
49+
}
50+
51+
/** The reveal shortcut label, `Shift+Alt+D` (`⇧⌥D` on macOS). */
52+
function revealShortcutLabel(): string {
53+
return isMac() ? '⇧⌥D' : 'Shift+Alt+D'
54+
}
55+
56+
function printHint(label: string): void {
57+
// Intentional user-facing hint (not a diagnostic) — the whole point of
58+
// passive mode is this one console line pointing at the reveal shortcut.
59+
// eslint-disable-next-line no-console
60+
console.info(
61+
`%c${label}%c is in passive mode — press %c${revealShortcutLabel()}%c to reveal the devtools.`,
62+
'font-weight:bold',
63+
'',
64+
'font-weight:bold',
65+
'',
66+
)
67+
}
68+
69+
/** `true` when the event is the reveal chord (Shift+Alt+D), layout-agnostic. */
70+
function isRevealChord(e: KeyboardEvent): boolean {
71+
return e.shiftKey && e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyD'
72+
}
73+
74+
export interface EmbeddedVisibilityHandlers {
75+
/** Mount/attach the dock element. */
76+
show: () => void
77+
/** Detach the dock element. */
78+
hide: () => void
79+
}
80+
81+
/**
82+
* Drive the embedded dock's reveal lifecycle for the resolved
83+
* {@link EmbeddedVisibility} mode: decide whether to show on boot, wire the
84+
* reveal shortcut and the "Hide" command's {@link HUB_UI_HIDE_EVENT}, and
85+
* persist the reveal for `passive`.
86+
*
87+
* `label` is the resolved product name, used in the passive-mode console hint.
88+
*/
89+
export function setupEmbeddedVisibility(
90+
mode: EmbeddedVisibility,
91+
label: string,
92+
handlers: EmbeddedVisibilityHandlers,
93+
): void {
94+
let shown = mode === 'normal' || (mode === 'passive' && readPersistedReveal())
95+
96+
function reveal(): void {
97+
if (shown)
98+
return
99+
shown = true
100+
if (mode === 'passive')
101+
persistReveal(true)
102+
handlers.show()
103+
}
104+
105+
function conceal(): void {
106+
if (!shown)
107+
return
108+
shown = false
109+
// `normal`/`hidden` hide for the session only; `passive` remembers it so
110+
// the next load starts hidden again.
111+
if (mode === 'passive')
112+
persistReveal(false)
113+
handlers.hide()
114+
}
115+
116+
if (shown)
117+
handlers.show()
118+
else if (mode === 'passive')
119+
printHint(label)
120+
121+
// Shift+Alt+D toggles the dock — the always-available "summon" chord.
122+
window.addEventListener('keydown', (e) => {
123+
if (!isRevealChord(e))
124+
return
125+
e.preventDefault()
126+
if (shown)
127+
conceal()
128+
else
129+
reveal()
130+
}, { capture: true })
131+
132+
// The in-dock "Hide" command asks to conceal (reload/shortcut brings it back).
133+
window.addEventListener(HUB_UI_HIDE_EVENT, conceal)
134+
}

‎packages/hub-ui/src/client/state/context.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,8 @@ export async function createDocksContext(
408408
// explicit visit and stays mounted.
409409
when: 'clientType == embedded',
410410
action: () => {
411-
// Ask whoever mounted the embedded dock to tear it down for this
412-
// session — a reload brings it back.
411+
// Conceal the embedded dock — the Shift+Alt+D reveal shortcut (or a
412+
// reload) brings it back. In passive mode this is remembered.
413413
window.dispatchEvent(new CustomEvent(HUB_UI_HIDE_EVENT))
414414
},
415415
},

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import type { DevframeHubUi } from '@devframes/hub/initiate'
2+
import type { EmbeddedVisibility } from './client/embedded/visibility'
23
import type { DevframeBranding } from './client/state/branding'
34
import { existsSync } from 'node:fs'
45
import { join } from 'node:path'
56
import { fileURLToPath } from 'node:url'
67

8+
export type { EmbeddedVisibility } from './client/embedded/visibility'
79
export type { DevframeBranding } from './client/state/branding'
810

911
declare module 'devframe/types' {
1012
interface DevframeConnectionConfigsRegistry {
11-
ui: { branding?: DevframeBranding }
13+
ui: { branding?: DevframeBranding, embeddedVisibility?: EmbeddedVisibility }
1214
}
1315
}
1416

@@ -40,6 +42,21 @@ export interface CreateUiOptions {
4042
* both the embedded dock and the standalone viewer.
4143
*/
4244
branding?: DevframeBranding
45+
/**
46+
* How the embedded floating dock reveals itself on a fresh page:
47+
*
48+
* - `'normal'` (default) — shown immediately.
49+
* - `'passive'` — starts hidden with a console hint; `Shift+Alt+D` reveals
50+
* it, and the reveal persists per-origin so later sessions start shown.
51+
* - `'hidden'` — starts hidden; `Shift+Alt+D` reveals it for the current
52+
* session only.
53+
*
54+
* Published as `ConnectionMeta.configs.ui.embeddedVisibility`. Like the
55+
* float/edge dock mode, it seeds a user-overridable preference — the
56+
* visitor's own reveal/hide wins from then on. Applies to the embedded
57+
* dock only; the standalone viewer is an explicit visit and always shows.
58+
*/
59+
embeddedVisibility?: EmbeddedVisibility
4360
}
4461

4562
/**
@@ -68,6 +85,9 @@ export function createUi(options: CreateUiOptions = {}): DevframeHubUi {
6885
...(options.embedded !== false
6986
? { embedded: { entry: join(client, 'embedded.js') } }
7087
: {}),
71-
configs: () => ({ branding: options.branding || {} }),
88+
configs: () => ({
89+
branding: options.branding || {},
90+
...(options.embeddedVisibility ? { embeddedVisibility: options.embeddedVisibility } : {}),
91+
}),
7292
}
7393
}

‎tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface CreateUiOptions {
66
viewer?: boolean;
77
embedded?: boolean;
88
branding?: DevframeBranding;
9+
embeddedVisibility?: EmbeddedVisibility;
910
}
1011
export interface DevframeBranding {
1112
productName?: string;
@@ -18,6 +19,10 @@ export interface DevframeBranding {
1819
}
1920
// #endregion
2021

22+
// #region Types
23+
export type EmbeddedVisibility = 'normal' | 'passive' | 'hidden';
24+
// #endregion
25+
2126
// #region Functions
2227
export declare function createUi(_?: CreateUiOptions): DevframeHubUi;
2328
// #endregion

0 commit comments

Comments
 (0)