Skip to content

Commit 1cefacd

Browse files
authored
feat(hub-ui): support embedded viewer backgrounds (#320)
1 parent 3c2e74a commit 1cefacd

11 files changed

Lines changed: 164 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
@@ -53,7 +53,7 @@ interface DevframeHubUi {
5353
`@devframes/hub-ui`'s `createUi()` is the reference (standalone `viewer` SPA + floating dock); its `setup(ctx)` publishes config to `ctx.staticConfig.ui` (`ConnectionMeta.configs.ui`):
5454

5555
- **`viewer`** — set to `false` to disable the standalone viewer.
56-
- **`branding`** — rebrand the UI (logo, name, primary color). `background` accepts any CSS `background` value (color, gradient, image, or `transparent`) or `{ light, dark }` variants; omit it to keep the design default.
56+
- **`branding`** — rebrand the UI (logo, name, primary color). `background` accepts any CSS `background` value (color, gradient, image, or `transparent`) or `{ light, dark }` variants. These flat forms apply everywhere. Use `{ standalone, iframe? }` to specialize the framed viewer; an omitted `iframe` value falls back to `standalone`.
5757
- **`dockPreferences`** — dock-rail: `categoryOrder`, floating-dock `maxVisibleItems`, first-run `defaultMode` (`'float'`/`'edge'`) and `defaultPosition`.
5858
- **`embeddedVisibility`** — the floating dock's reveal policy:
5959
- `'normal'` (default) — shows immediately.

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@
1414
color-scheme: dark;
1515
--devframes-viewer-background: #111;
1616
}
17-
html.viewer-background-custom {
18-
color-scheme: normal;
19-
}
2017
html,
2118
body {
2219
margin: 0;

‎packages/hub-ui/src/client/standalone/main.ts‎

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { watchEffect } from 'vue'
55
import { applyDocumentHead, applyPrimaryColor, setBranding, useBrandingBackground } from '../state/branding'
66
import { isDark } from '../state/color-mode'
77
import { DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
8+
import { applyViewerBackground } from './viewer-background'
89

910
// The standalone viewer — a vanilla shell served at the hub base itself
1011
// (`DevframeHubUi.viewer`): resolve the shared connection, build the docks
@@ -13,21 +14,9 @@ import { DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
1314
// custom element's shadow root.
1415

1516
// The standalone viewer runs in the light DOM, so mirror the color mode onto the
16-
// document element — its background follows the Auto/Light/Dark choice. The
17-
// component tree carries `color-scheme` for its native controls; keeping that
18-
// off the document lets custom backgrounds composite with the host page.
19-
const brandingBackground = useBrandingBackground()
20-
21-
function applyViewerBackground(documentElement: HTMLElement, background: string | undefined): void {
22-
if (background === undefined || !CSS.supports('background', background)) {
23-
documentElement.classList.remove('viewer-background-custom')
24-
documentElement.style.removeProperty('--devframes-viewer-background')
25-
return
26-
}
27-
28-
documentElement.classList.add('viewer-background-custom')
29-
documentElement.style.setProperty('--devframes-viewer-background', background)
30-
}
17+
// document element — its background and foreground controls follow the
18+
// Auto/Light/Dark choice, including when branding supplies a custom background.
19+
const brandingBackground = useBrandingBackground(window.self !== window.top ? 'iframe' : 'standalone')
3120

3221
watchEffect(() => {
3322
const el = document.documentElement
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { ViewerBackgroundElement } from './viewer-background'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { applyViewerBackground } from './viewer-background'
4+
5+
function createElement(): ViewerBackgroundElement {
6+
return {
7+
style: {
8+
removeProperty: vi.fn(),
9+
setProperty: vi.fn(),
10+
},
11+
}
12+
}
13+
14+
describe('applyViewerBackground', () => {
15+
it('applies a supported CSS background', () => {
16+
expect.assertions(3)
17+
18+
const element = createElement()
19+
const supports = vi.fn(() => true)
20+
21+
applyViewerBackground(element, 'linear-gradient(white, transparent)', supports)
22+
23+
expect(supports).toHaveBeenCalledWith('background', 'linear-gradient(white, transparent)')
24+
expect(element.style.setProperty).toHaveBeenCalledWith('--devframes-viewer-background', 'linear-gradient(white, transparent)')
25+
expect(element.style.removeProperty).not.toHaveBeenCalled()
26+
})
27+
28+
it.each([undefined, 'not-a-background'])('restores the default for %s', (background) => {
29+
expect.assertions(2)
30+
31+
const element = createElement()
32+
const supports = vi.fn(() => false)
33+
34+
applyViewerBackground(element, background, supports)
35+
36+
expect(element.style.removeProperty).toHaveBeenCalledWith('--devframes-viewer-background')
37+
expect(element.style.setProperty).not.toHaveBeenCalled()
38+
})
39+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export interface ViewerBackgroundElement {
2+
style: Pick<CSSStyleDeclaration, 'removeProperty' | 'setProperty'>
3+
}
4+
5+
/** Apply a validated branding background to the standalone viewer document. */
6+
export function applyViewerBackground(
7+
documentElement: ViewerBackgroundElement,
8+
background: string | undefined,
9+
supports = (property: string, value: string): boolean => CSS.supports(property, value),
10+
): void {
11+
if (background === undefined || !supports('background', background)) {
12+
documentElement.style.removeProperty('--devframes-viewer-background')
13+
return
14+
}
15+
16+
documentElement.style.setProperty('--devframes-viewer-background', background)
17+
}

‎packages/hub-ui/src/client/state/branding.test.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { DevframeBranding } from '../../types'
12
import { afterEach, describe, expect, it } from 'vitest'
23
import { setBranding, useBrandingBackground } from './branding'
34
import { setColorSchemePreference } from './color-mode'
@@ -8,12 +9,55 @@ afterEach(() => {
89
})
910

1011
describe('useBrandingBackground', () => {
12+
it.each([
13+
{ viewerContext: 'standalone' as const, preference: 'light' as const, expected: 'standalone-light' },
14+
{ viewerContext: 'standalone' as const, preference: 'dark' as const, expected: 'standalone-dark' },
15+
{ viewerContext: 'iframe' as const, preference: 'light' as const, expected: 'iframe-light' },
16+
{ viewerContext: 'iframe' as const, preference: 'dark' as const, expected: 'iframe-dark' },
17+
])('resolves the $preference background in the $viewerContext viewer', ({ viewerContext, preference, expected }) => {
18+
expect.assertions(1)
19+
20+
setColorSchemePreference(preference)
21+
setBranding({
22+
background: {
23+
standalone: { light: 'standalone-light', dark: 'standalone-dark' },
24+
iframe: { light: 'iframe-light', dark: 'iframe-dark' },
25+
},
26+
})
27+
28+
expect(useBrandingBackground(viewerContext).value).toBe(expected)
29+
})
30+
31+
it('falls back to the standalone background when no iframe value is configured', () => {
32+
expect.assertions(1)
33+
34+
setBranding({ background: { standalone: 'shared' } })
35+
36+
expect(useBrandingBackground('iframe').value).toBe('shared')
37+
})
38+
39+
it.each(['standalone', 'iframe'] as const)('applies a flat background in the %s viewer', (viewerContext) => {
40+
expect.assertions(1)
41+
42+
setBranding({ background: 'shared' })
43+
44+
expect(useBrandingBackground(viewerContext).value).toBe('shared')
45+
})
46+
1147
it('preserves an empty dark value for CSS validation', () => {
1248
expect.assertions(1)
1349

1450
setColorSchemePreference('dark')
1551
setBranding({ background: { light: 'white', dark: '' } })
1652

17-
expect(useBrandingBackground().value).toBe('')
53+
expect(useBrandingBackground('standalone').value).toBe('')
54+
})
55+
56+
it('ignores a null background from an invalid runtime configuration', () => {
57+
expect.assertions(1)
58+
59+
setBranding({ background: null } as unknown as DevframeBranding)
60+
61+
expect(useBrandingBackground('iframe').value).toBeUndefined()
1862
})
1963
})

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import type { Ref } from 'vue'
2-
import type { BrandingLogo, DevframeBranding } from '../../types'
2+
import type { BrandingLogo, ColorSchemeValue, DevframeBranding, ViewerBackground } from '../../types'
33
import { computed, ref } from 'vue'
44
import { isDark } from './color-mode'
55

6-
type ColorSchemeValue = string | { light: string, dark: string }
7-
86
/** Branding with defaults resolved — what the UI actually renders. */
97
export interface ResolvedBranding {
108
productName: string
@@ -51,9 +49,24 @@ export function useBrandingLogo(pick: (b: ResolvedBranding) => BrandingLogo | un
5149
return computed(() => resolveColorSchemeValue(pick(currentBranding.value), isDark.value))
5250
}
5351

54-
/** The standalone viewer background for the current color scheme. */
55-
export function useBrandingBackground(): Ref<string | undefined> {
56-
return computed(() => resolveColorSchemeValue(currentBranding.value.background, isDark.value))
52+
/** The standalone viewer background for its frame context and current color scheme. */
53+
export function useBrandingBackground(viewerContext: 'standalone' | 'iframe'): Ref<string | undefined> {
54+
return computed(() => {
55+
const configuredBackground = currentBranding.value.background
56+
57+
if (!isContextualViewerBackground(configuredBackground))
58+
return resolveColorSchemeValue(configuredBackground, isDark.value)
59+
60+
let contextualBackground = configuredBackground.standalone
61+
if (viewerContext === 'iframe')
62+
contextualBackground = configuredBackground.iframe ?? contextualBackground
63+
64+
return resolveColorSchemeValue(contextualBackground, isDark.value)
65+
})
66+
}
67+
68+
function isContextualViewerBackground(value: unknown): value is Extract<ViewerBackground, { standalone: ColorSchemeValue }> {
69+
return typeof value === 'object' && value !== null && 'standalone' in value
5770
}
5871

5972
function resolveColorSchemeValue(value: ColorSchemeValue | undefined, dark: boolean): string | undefined {

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ describe('createUi branding background', () => {
1515
const html = readFileSync(fileURLToPath(new URL('../dist/client/standalone/index.html', import.meta.url)), 'utf8')
1616

1717
expect(html).not.toContain('__hub-ui.css')
18-
expect(html).toContain('html.viewer-background-custom')
18+
expect(html).toContain('color-scheme: light')
1919
expect(html).toContain('--devframes-viewer-background: #fff')
2020
expect(html).toContain('--devframes-viewer-background: #111')
2121
expect(html).toContain('background: var(--devframes-viewer-background)')
22-
expect(html).toMatch(/html\.viewer-background-custom\s*\{[^}]*color-scheme:\s*normal/)
22+
expect(html).not.toMatch(/html\.viewer-background-custom\s*\{[^}]*color-scheme:\s*normal/)
2323
})
2424

2525
it('preserves the default viewer background', () => {
@@ -60,6 +60,19 @@ describe('createUi branding background', () => {
6060
expect(context.staticConfig.ui).toEqual({ branding: { background } })
6161
})
6262

63+
it('publishes standalone and iframe viewer backgrounds with the branding', () => {
64+
expect.assertions(1)
65+
66+
const background = {
67+
standalone: { light: '#fff', dark: '#282828' },
68+
iframe: 'transparent',
69+
}
70+
const ui = createUi({ branding: { background } })
71+
const context = createContext()
72+
ui.setup?.(context)
73+
expect(context.staticConfig.ui).toEqual({ branding: { background } })
74+
})
75+
6376
it('disables the standalone viewer', () => {
6477
expect.assertions(1)
6578

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
44
import { join } from 'node:path'
55
import { fileURLToPath } from 'node:url'
66

7-
export type { DevframeBranding, DevframeDockPreferences, EmbeddedVisibility } from './types'
7+
export type { ColorSchemeValue, DevframeBranding, DevframeDockPreferences, EmbeddedVisibility, ViewerBackground } from './types'
88

99
declare module 'devframe/types' {
1010
interface DevframeConnectionConfigsRegistry {

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@
1313
*/
1414
export type BrandingLogo = string | { light: string, dark: string }
1515

16+
/** A value that can vary with the viewer color scheme. */
17+
export type ColorSchemeValue = string | { light: string, dark: string }
18+
19+
/**
20+
* The standalone viewer background. The flat form applies in every context;
21+
* the structured form may provide an iframe-specific value.
22+
*/
23+
export type ViewerBackground = ColorSchemeValue | {
24+
standalone: ColorSchemeValue
25+
iframe?: ColorSchemeValue
26+
}
27+
1628
/**
1729
* Consumer-facing branding for the reference hub-ui. Every field is optional
1830
* and falls back to devframe's own identity. Published as
@@ -31,8 +43,8 @@ export interface DevframeBranding {
3143
wordmark?: BrandingLogo
3244
/** Brand color; feeds `--devframe-primary` and the whole primary ramp. */
3345
primaryColor?: string
34-
/** Standalone viewer CSS `background`; a string applies to both color schemes. */
35-
background?: string | { light: string, dark: string }
46+
/** Standalone viewer CSS `background`, optionally specialized for iframe use. */
47+
background?: ViewerBackground
3648
/** Short line for the auth screen and the standalone meta description. */
3749
tagline?: string
3850
/** Favicon URL — applied on the standalone viewer and the popped-out window only. */

0 commit comments

Comments
 (0)