Skip to content

Commit 28deb2f

Browse files
committed
feat(hub-ui): add seamless iframe presentation
1 parent 9649672 commit 28deb2f

15 files changed

Lines changed: 113 additions & 10 deletions

File tree

docs/content/1.guide/16.hub.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,21 @@ ctx.docks.register({
214214

215215
Group and members stay independent top-level entries in `devframe:docks`; `defaultChildId` opens on activation. Grouping affects the dock bar, not iframes — to share **one** soft-navigated iframe, give docks a shared `frameId` and mark the anchor with `subTabs` ([Shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation)).
216216

217+
An iframe that should visually share its host's surface can opt into seamless presentation:
218+
219+
```ts
220+
ctx.docks.register({
221+
type: 'iframe',
222+
id: 'embedded-dashboard',
223+
title: 'Dashboard',
224+
icon: 'ph:gauge-duotone',
225+
url: 'http://localhost:4000/',
226+
presentation: 'seamless',
227+
})
228+
```
229+
230+
The reference Hub UI removes its address bar, opaque loading surface, border, background, shadow, and corner radius for that entry. Other viewers may provide an equivalent treatment or retain their default presentation. The embedded document must also use a transparent background if the host surface should remain visible.
231+
217232
### The dual role of `category`
218233

219234
`category` (ordered by `DEFAULT_CATEGORIES_ORDER`, default `'default'`) sets an **ungrouped** entry's outer dock-bar bucket. A **grouped** entry takes its outer bucket from the **group's** `category`, and its own `category` becomes an **in-group sub-category**; a member whose `groupId` never resolves renders top-level under its own `category`.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ interface DevframeHubUi {
5959

6060
`@devframes/hub-ui`'s `createUi()` is the reference (viewer + floating dock); its `setup(ctx)` publishes config to `ctx.staticConfig.ui` (`ConnectionMeta.configs.ui`):
6161

62+
- **`viewer`** — set to `false` to disable the standalone viewer, or `{ background: 'transparent' }` when its document should visually inherit an embedding page's surface.
6263
- **`branding`** — rebrand the UI (logo, name, primary color).
6364
- **`dockPreferences`** — dock-bar: `categoryOrder`, floating-dock `maxVisibleItems`, first-run `defaultMode` (`'float'`/`'edge'`) and `defaultPosition`.
6465
- **`embeddedVisibility`** — the floating dock's reveal policy:

packages/hub-ui/src/client/components/dock/DockEdge.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const isVertical = computed(() => store.position === 'left' || store.position ==
3434
3535
const groupedEntries = computed(() => context.docks.groupedEntries)
3636
const selectedEntry = computed(() => context.docks.selected)
37+
const isSeamlessIframe = computed(() => selectedEntry.value?.type === 'iframe' && selectedEntry.value.presentation === 'seamless')
3738
const activeGroup = computed(() => getEntryGroup(context.docks.entries, selectedEntry.value))
3839
const hasPanelContent = computed(() => {
3940
const entry = selectedEntry.value
@@ -407,8 +408,12 @@ const dragPreviewStyle = computed<CSSProperties | undefined>(() => {
407408
<template>
408409
<div
409410
id="devframes-edge-panel"
410-
class="bg-dock-glass border border-base color-base shadow overflow-clip z-floating-anchor font-sans text-[15px] box-border"
411-
:class="[panelLayoutClass, { 'devframes-edge-collapsed': isCollapsed }]"
411+
class="color-base overflow-clip z-floating-anchor font-sans text-[15px] box-border"
412+
:class="[
413+
panelLayoutClass,
414+
isSeamlessIframe ? 'bg-transparent border-0 shadow-none' : 'bg-dock-glass border border-base shadow',
415+
{ 'devframes-edge-collapsed': isCollapsed },
416+
]"
412417
:style="panelStyle"
413418
@mousemove="bringUp"
414419
>

packages/hub-ui/src/client/components/dock/DockPanel.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const layout = computed(() => props.layout ?? DEFAULT_DOCK_LAYOUT)
2929
3030
// When the open entry belongs to a group, surface its siblings in a sidebar.
3131
const activeGroup = computed(() => getEntryGroup(context.docks.entries, selected.value))
32+
const isSeamlessIframe = computed(() => selected.value?.type === 'iframe' && selected.value.presentation === 'seamless')
3233
3334
const windowSize = reactive(useWindowSize())
3435
const isHovering = ref(false)
@@ -175,7 +176,8 @@ onMounted(() => {
175176
<div
176177
v-show="context.docks.selected && context.docks.selected.type !== 'action'"
177178
ref="dockPanel"
178-
class="bg-dock-glass rounded-lg border border-base color-base shadow overflow-hidden"
179+
class="color-base overflow-hidden"
180+
:class="isSeamlessIframe ? 'bg-transparent border-0 rounded-0 shadow-none' : 'bg-dock-glass rounded-lg border border-base shadow'"
179181
:style="panelStyle"
180182
@contextmenu="openContextMenu"
181183
>

packages/hub-ui/src/client/components/views/ViewIframe.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ const props = defineProps<{
2020
2121
const settings = sharedStateToRef(props.context.docks.settings)
2222
const isEdgeMode = computed(() => props.context.panel.store.mode === 'edge')
23-
const showAddressBar = computed(() => settings.value.showIframeAddressBar ?? true)
23+
const isSeamless = computed(() => props.entry.presentation === 'seamless')
24+
const showAddressBar = computed(() => !isSeamless.value && (settings.value.showIframeAddressBar ?? true))
2425
2526
const ADDRESS_BAR_HEIGHT = 40
2627
@@ -262,6 +263,11 @@ onMounted(() => {
262263
// cosmetic borders differ between edge/float and address-bar states.
263264
watchEffect(() => {
264265
Object.assign(iframe.style, props.iframeStyle)
266+
if (isSeamless.value) {
267+
iframe.style.border = 'none'
268+
iframe.style.borderRadius = '0px'
269+
return
270+
}
265271
if (showAddressBar.value && !isEdgeMode.value) {
266272
iframe.style.borderTopLeftRadius = '0px'
267273
iframe.style.borderTopRightRadius = '0px'
@@ -381,7 +387,7 @@ onUnmounted(() => {
381387
ref="viewFrame"
382388
class="devframes-view-iframe relative w-full h-full flex-1 items-center justify-center"
383389
>
384-
<ViewIframeLoading v-if="showLoadingPlaceholder" />
390+
<ViewIframeLoading v-if="showLoadingPlaceholder" :transparent="isSeamless" />
385391
<ViewAssetsError
386392
v-if="assetsError"
387393
:error="assetsError"

packages/hub-ui/src/client/components/views/ViewIframeLoading.stories.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@ export const Loading: Story = {
2929
setup: () => () => stage(h(ViewIframeLoading)),
3030
}),
3131
}
32+
33+
export const Transparent: Story = {
34+
render: () => ({
35+
setup: () => () => stage(h(ViewIframeLoading, { transparent: true })),
36+
}),
37+
}

packages/hub-ui/src/client/components/views/ViewIframeLoading.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
<script setup lang="ts">
2+
defineProps<{
3+
transparent?: boolean
4+
}>()
5+
26
// Placeholder shown while an iframe view loads its content. A blank iframe
37
// paints white during load, so this is only visible once the pane steps aside
48
// (`pane.hide()` in `ViewIframe`) — the same layering trick `ViewAssetsError`
59
// relies on. It covers the initial load and any hard navigation/refresh.
610
</script>
711

812
<template>
9-
<div class="devframes-view-iframe-loading absolute inset-0 flex flex-col items-center justify-center gap-2 bg-base">
13+
<div
14+
class="devframes-view-iframe-loading absolute inset-0 flex flex-col items-center justify-center gap-2"
15+
:class="transparent ? 'bg-transparent' : 'bg-base'"
16+
>
1017
<div class="i-ph:circle-notch-duotone animate-spin text-3xl color-faint" />
1118
<div class="text-sm color-muted">
1219
Loading…

packages/hub-ui/src/client/standalone/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>Devframes</title>
77
<meta name="description" content="Devframes hub" />
8+
<link rel="stylesheet" href="./__hub-ui.css" />
89
<style>
910
html,
1011
body {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createUi } from './index'
3+
4+
describe('createUi viewer background', () => {
5+
it('preserves the default viewer background', () => {
6+
expect.assertions(1)
7+
8+
const ui = createUi()
9+
10+
expect(ui.assets?.['__hub-ui.css']()).toBe('')
11+
})
12+
13+
it('provides a transparent viewer background when requested', () => {
14+
expect.assertions(1)
15+
16+
const ui = createUi({ viewer: { background: 'transparent' } })
17+
18+
expect(ui.assets?.['__hub-ui.css']()).toBe('html,body{background:transparent!important}')
19+
})
20+
21+
it('does not publish viewer assets when the viewer is disabled', () => {
22+
expect.assertions(2)
23+
24+
const ui = createUi({ viewer: false })
25+
26+
expect(ui.viewer).toBeUndefined()
27+
expect(ui.assets).toBeUndefined()
28+
})
29+
})

packages/hub-ui/src/index.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ function clientDir(): string {
3333
}
3434

3535
export interface CreateUiOptions {
36-
/** Serve the standalone viewer SPA at the hub base. Default: `true`. */
37-
viewer?: boolean
36+
/**
37+
* Serve the standalone viewer SPA at the hub base. Pass an object to
38+
* customize the viewer surface. Default: `true`.
39+
*/
40+
viewer?: boolean | {
41+
/** Page background used around transparent renderer content. Default: `'default'`. */
42+
background?: 'default' | 'transparent'
43+
}
3844
/** Serve the floating-dock bootstrap at `<base>embedded.js`. Default: `true`. */
3945
embedded?: boolean
4046
/**
@@ -87,13 +93,23 @@ export interface CreateUiOptions {
8793
*/
8894
export function createUi(options: CreateUiOptions = {}): DevframeHubUi {
8995
const client = clientDir()
96+
const viewerBackground = typeof options.viewer === 'object' ? options.viewer.background : undefined
9097
return {
9198
...(options.viewer !== false
9299
? { viewer: { distDir: join(client, 'standalone') } }
93100
: {}),
94101
...(options.embedded !== false
95102
? { embedded: { entry: join(client, 'embedded.js') } }
96103
: {}),
104+
...(options.viewer !== false
105+
? {
106+
assets: {
107+
'__hub-ui.css': () => viewerBackground === 'transparent'
108+
? 'html,body{background:transparent!important}'
109+
: '',
110+
},
111+
}
112+
: {}),
97113
// Publish the reference UI's config through the generic `ctx.staticConfig`
98114
// — it rides the connection handshake to every mounted frame and the
99115
// standalone viewer as `ConnectionMeta.configs.ui`.

0 commit comments

Comments
 (0)