Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ interface DevframeHubUi {
`@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`):

- **`viewer`** — set to `false` to disable the standalone viewer.
- **`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.
- **`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`.
- **`dockPreferences`** — dock-rail: `categoryOrder`, floating-dock `maxVisibleItems`, first-run `defaultMode` (`'float'`/`'edge'`) and `defaultPosition`.
- **`embeddedVisibility`** — the floating dock's reveal policy:
- `'normal'` (default) — shows immediately.
Expand Down
3 changes: 0 additions & 3 deletions packages/hub-ui/src/client/standalone/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@
color-scheme: dark;
--devframes-viewer-background: #111;
}
html.viewer-background-custom {
color-scheme: normal;
}
html,
body {
margin: 0;
Expand Down
19 changes: 4 additions & 15 deletions packages/hub-ui/src/client/standalone/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { watchEffect } from 'vue'
import { applyDocumentHead, applyPrimaryColor, setBranding, useBrandingBackground } from '../state/branding'
import { isDark } from '../state/color-mode'
import { DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
import { applyViewerBackground } from './viewer-background'

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

// The standalone viewer runs in the light DOM, so mirror the color mode onto the
// document element — its background follows the Auto/Light/Dark choice. The
// component tree carries `color-scheme` for its native controls; keeping that
// off the document lets custom backgrounds composite with the host page.
const brandingBackground = useBrandingBackground()

function applyViewerBackground(documentElement: HTMLElement, background: string | undefined): void {
if (background === undefined || !CSS.supports('background', background)) {
documentElement.classList.remove('viewer-background-custom')
documentElement.style.removeProperty('--devframes-viewer-background')
return
}

documentElement.classList.add('viewer-background-custom')
documentElement.style.setProperty('--devframes-viewer-background', background)
}
// document element — its background and foreground controls follow the
// Auto/Light/Dark choice, including when branding supplies a custom background.
const brandingBackground = useBrandingBackground(window.self !== window.top ? 'iframe' : 'standalone')

watchEffect(() => {
const el = document.documentElement
Expand Down
39 changes: 39 additions & 0 deletions packages/hub-ui/src/client/standalone/viewer-background.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ViewerBackgroundElement } from './viewer-background'
import { describe, expect, it, vi } from 'vitest'
import { applyViewerBackground } from './viewer-background'

function createElement(): ViewerBackgroundElement {
return {
style: {
removeProperty: vi.fn(),
setProperty: vi.fn(),
},
}
}

describe('applyViewerBackground', () => {
it('applies a supported CSS background', () => {
expect.assertions(3)

const element = createElement()
const supports = vi.fn(() => true)

applyViewerBackground(element, 'linear-gradient(white, transparent)', supports)

expect(supports).toHaveBeenCalledWith('background', 'linear-gradient(white, transparent)')
expect(element.style.setProperty).toHaveBeenCalledWith('--devframes-viewer-background', 'linear-gradient(white, transparent)')
expect(element.style.removeProperty).not.toHaveBeenCalled()
})

it.each([undefined, 'not-a-background'])('restores the default for %s', (background) => {
expect.assertions(2)

const element = createElement()
const supports = vi.fn(() => false)

applyViewerBackground(element, background, supports)

expect(element.style.removeProperty).toHaveBeenCalledWith('--devframes-viewer-background')
expect(element.style.setProperty).not.toHaveBeenCalled()
})
})
17 changes: 17 additions & 0 deletions packages/hub-ui/src/client/standalone/viewer-background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface ViewerBackgroundElement {
style: Pick<CSSStyleDeclaration, 'removeProperty' | 'setProperty'>
}

/** Apply a validated branding background to the standalone viewer document. */
export function applyViewerBackground(
documentElement: ViewerBackgroundElement,
background: string | undefined,
supports = (property: string, value: string): boolean => CSS.supports(property, value),
): void {
if (background === undefined || !supports('background', background)) {
documentElement.style.removeProperty('--devframes-viewer-background')
return
}

documentElement.style.setProperty('--devframes-viewer-background', background)
Comment thread
dvcolomban marked this conversation as resolved.
}
46 changes: 45 additions & 1 deletion packages/hub-ui/src/client/state/branding.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { DevframeBranding } from '../../types'
import { afterEach, describe, expect, it } from 'vitest'
import { setBranding, useBrandingBackground } from './branding'
import { setColorSchemePreference } from './color-mode'
Expand All @@ -8,12 +9,55 @@ afterEach(() => {
})

describe('useBrandingBackground', () => {
it.each([
{ viewerContext: 'standalone' as const, preference: 'light' as const, expected: 'standalone-light' },
{ viewerContext: 'standalone' as const, preference: 'dark' as const, expected: 'standalone-dark' },
{ viewerContext: 'iframe' as const, preference: 'light' as const, expected: 'iframe-light' },
{ viewerContext: 'iframe' as const, preference: 'dark' as const, expected: 'iframe-dark' },
])('resolves the $preference background in the $viewerContext viewer', ({ viewerContext, preference, expected }) => {
expect.assertions(1)

setColorSchemePreference(preference)
setBranding({
background: {
standalone: { light: 'standalone-light', dark: 'standalone-dark' },
iframe: { light: 'iframe-light', dark: 'iframe-dark' },
},
})

expect(useBrandingBackground(viewerContext).value).toBe(expected)
})

it('falls back to the standalone background when no iframe value is configured', () => {
expect.assertions(1)

setBranding({ background: { standalone: 'shared' } })

expect(useBrandingBackground('iframe').value).toBe('shared')
})

it.each(['standalone', 'iframe'] as const)('applies a flat background in the %s viewer', (viewerContext) => {
expect.assertions(1)

setBranding({ background: 'shared' })

expect(useBrandingBackground(viewerContext).value).toBe('shared')
})

it('preserves an empty dark value for CSS validation', () => {
expect.assertions(1)

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

expect(useBrandingBackground().value).toBe('')
expect(useBrandingBackground('standalone').value).toBe('')
})

it('ignores a null background from an invalid runtime configuration', () => {
expect.assertions(1)

setBranding({ background: null } as unknown as DevframeBranding)

expect(useBrandingBackground('iframe').value).toBeUndefined()
})
})
25 changes: 19 additions & 6 deletions packages/hub-ui/src/client/state/branding.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import type { Ref } from 'vue'
import type { BrandingLogo, DevframeBranding } from '../../types'
import type { BrandingLogo, ColorSchemeValue, DevframeBranding, ViewerBackground } from '../../types'
import { computed, ref } from 'vue'
import { isDark } from './color-mode'

type ColorSchemeValue = string | { light: string, dark: string }

/** Branding with defaults resolved — what the UI actually renders. */
export interface ResolvedBranding {
productName: string
Expand Down Expand Up @@ -51,9 +49,24 @@ export function useBrandingLogo(pick: (b: ResolvedBranding) => BrandingLogo | un
return computed(() => resolveColorSchemeValue(pick(currentBranding.value), isDark.value))
}

/** The standalone viewer background for the current color scheme. */
export function useBrandingBackground(): Ref<string | undefined> {
return computed(() => resolveColorSchemeValue(currentBranding.value.background, isDark.value))
/** The standalone viewer background for its frame context and current color scheme. */
export function useBrandingBackground(viewerContext: 'standalone' | 'iframe'): Ref<string | undefined> {
return computed(() => {
const configuredBackground = currentBranding.value.background

if (!isContextualViewerBackground(configuredBackground))
return resolveColorSchemeValue(configuredBackground, isDark.value)

let contextualBackground = configuredBackground.standalone
if (viewerContext === 'iframe')
contextualBackground = configuredBackground.iframe ?? contextualBackground

return resolveColorSchemeValue(contextualBackground, isDark.value)
})
}

function isContextualViewerBackground(value: unknown): value is Extract<ViewerBackground, { standalone: ColorSchemeValue }> {
return typeof value === 'object' && value !== null && 'standalone' in value
}

function resolveColorSchemeValue(value: ColorSchemeValue | undefined, dark: boolean): string | undefined {
Expand Down
17 changes: 15 additions & 2 deletions packages/hub-ui/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ describe('createUi branding background', () => {
const html = readFileSync(fileURLToPath(new URL('../dist/client/standalone/index.html', import.meta.url)), 'utf8')

expect(html).not.toContain('__hub-ui.css')
expect(html).toContain('html.viewer-background-custom')
expect(html).toContain('color-scheme: light')
expect(html).toContain('--devframes-viewer-background: #fff')
expect(html).toContain('--devframes-viewer-background: #111')
expect(html).toContain('background: var(--devframes-viewer-background)')
expect(html).toMatch(/html\.viewer-background-custom\s*\{[^}]*color-scheme:\s*normal/)
expect(html).not.toMatch(/html\.viewer-background-custom\s*\{[^}]*color-scheme:\s*normal/)
})

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

it('publishes standalone and iframe viewer backgrounds with the branding', () => {
expect.assertions(1)

const background = {
standalone: { light: '#fff', dark: '#282828' },
iframe: 'transparent',
}
const ui = createUi({ branding: { background } })
const context = createContext()
ui.setup?.(context)
expect(context.staticConfig.ui).toEqual({ branding: { background } })
})

it('disables the standalone viewer', () => {
expect.assertions(1)

Expand Down
2 changes: 1 addition & 1 deletion packages/hub-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'

export type { DevframeBranding, DevframeDockPreferences, EmbeddedVisibility } from './types'
export type { ColorSchemeValue, DevframeBranding, DevframeDockPreferences, EmbeddedVisibility, ViewerBackground } from './types'

declare module 'devframe/types' {
interface DevframeConnectionConfigsRegistry {
Expand Down
16 changes: 14 additions & 2 deletions packages/hub-ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@
*/
export type BrandingLogo = string | { light: string, dark: string }

/** A value that can vary with the viewer color scheme. */
export type ColorSchemeValue = string | { light: string, dark: string }

/**
* The standalone viewer background. The flat form applies in every context;
* the structured form may provide an iframe-specific value.
*/
export type ViewerBackground = ColorSchemeValue | {
standalone: ColorSchemeValue
iframe?: ColorSchemeValue
}

/**
* Consumer-facing branding for the reference hub-ui. Every field is optional
* and falls back to devframe's own identity. Published as
Expand All @@ -31,8 +43,8 @@ export interface DevframeBranding {
wordmark?: BrandingLogo
/** Brand color; feeds `--devframe-primary` and the whole primary ramp. */
primaryColor?: string
/** Standalone viewer CSS `background`; a string applies to both color schemes. */
background?: string | { light: string, dark: string }
/** Standalone viewer CSS `background`, optionally specialized for iframe use. */
background?: ViewerBackground
/** Short line for the auth screen and the standalone meta description. */
tagline?: string
/** Favicon URL — applied on the standalone viewer and the popped-out window only. */
Expand Down
13 changes: 9 additions & 4 deletions tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ export interface DevframeBranding {
logo?: BrandingLogo;
wordmark?: BrandingLogo;
primaryColor?: string;
background?: string | {
light: string;
dark: string;
};
background?: ViewerBackground;
tagline?: string;
favicon?: string;
windowTitle?: string;
Expand All @@ -31,7 +28,15 @@ export interface DevframeDockPreferences {
// #endregion

// #region Types
export type ColorSchemeValue = string | {
light: string;
dark: string;
};
export type EmbeddedVisibility = 'normal' | 'passive' | 'hidden';
export type ViewerBackground = ColorSchemeValue | {
standalone: ColorSchemeValue;
iframe?: ColorSchemeValue;
};
// #endregion

// #region Functions
Expand Down
Loading