Skip to content
3 changes: 2 additions & 1 deletion docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ 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`):

- **`branding`** — rebrand the UI (logo, name, primary color).
- **`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.
- **`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
17 changes: 12 additions & 5 deletions packages/hub-ui/src/client/standalone/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@
<title>Devframes</title>
<meta name="description" content="Devframes hub" />
<style>
:root {
color-scheme: light;
--devframes-viewer-background: #fff;
}
html.dark {
color-scheme: dark;
--devframes-viewer-background: #111;
}
html.viewer-background-custom {
color-scheme: normal;
}
html,
body {
margin: 0;
height: 100%;
background: #fff;
}
html.dark,
html.dark body {
background: #111;
background: var(--devframes-viewer-background);
}
#app {
height: 100%;
Expand Down
24 changes: 19 additions & 5 deletions packages/hub-ui/src/client/standalone/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { DockSessionStorage } from '@devframes/hub/client'
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
import { useSessionStorage } from '@vueuse/core'
import { watchEffect } from 'vue'
import { applyDocumentHead, applyPrimaryColor, setBranding } from '../state/branding'
import { applyDocumentHead, applyPrimaryColor, setBranding, useBrandingBackground } from '../state/branding'
import { isDark } from '../state/color-mode'
import { DEFAULT_DOCK_SESSION_STORE } from '../state/docks'

Expand All @@ -12,14 +12,28 @@ import { DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
// shell stays frameworkless on purpose; everything visual lives inside the
// custom element's shadow root.

// The standalone page runs in the light DOM, so mirror the color mode onto the
// document element — its background and native controls follow the
// Auto/Light/Dark choice.
// 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)
}

watchEffect(() => {
const el = document.documentElement
el.classList.toggle('dark', isDark.value)
el.classList.toggle('light', !isDark.value)
el.style.colorScheme = isDark.value ? 'dark' : 'light'
applyViewerBackground(el, brandingBackground.value)
})

async function main(): Promise<void> {
Expand Down
19 changes: 19 additions & 0 deletions packages/hub-ui/src/client/state/branding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setBranding, useBrandingBackground } from './branding'
import { setColorSchemePreference } from './color-mode'

afterEach(() => {
setBranding({})
setColorSchemePreference('auto')
})

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

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

expect(useBrandingBackground().value).toBe('')
})
})
21 changes: 15 additions & 6 deletions packages/hub-ui/src/client/state/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import type { BrandingLogo, DevframeBranding } 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
logo?: BrandingLogo
wordmark?: BrandingLogo
primaryColor?: string
background?: DevframeBranding['background']
tagline?: string
favicon?: string
windowTitle: string
Expand All @@ -23,6 +26,7 @@ function resolveDefaults(branding: DevframeBranding): ResolvedBranding {
logo: branding.logo,
wordmark: branding.wordmark,
primaryColor: branding.primaryColor,
background: branding.background,
tagline: branding.tagline,
favicon: branding.favicon,
windowTitle: branding.windowTitle?.trim() || productName,
Expand All @@ -44,15 +48,20 @@ export function setBranding(branding: DevframeBranding): ResolvedBranding {

/** The logo/wordmark URL for the current color scheme, reactive to it. */
export function useBrandingLogo(pick: (b: ResolvedBranding) => BrandingLogo | undefined = b => b.logo): Ref<string | undefined> {
return computed(() => resolveLogo(pick(currentBranding.value), isDark.value))
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))
}

function resolveLogo(logo: BrandingLogo | undefined, dark: boolean): string | undefined {
if (!logo)
function resolveColorSchemeValue(value: ColorSchemeValue | undefined, dark: boolean): string | undefined {
if (!value)
return undefined
if (typeof logo === 'string')
return logo
return dark ? (logo.dark || logo.light) : logo.light
if (typeof value === 'string')
return value
return dark ? (value.dark ?? value.light) : value.light
}

// --- Applying to the DOM --------------------------------------------------
Expand Down
70 changes: 70 additions & 0 deletions packages/hub-ui/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { DevframeHubContext } from '@devframes/hub/node'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { createUi } from './index'

function createContext(): DevframeHubContext {
return { staticConfig: {} } as unknown as DevframeHubContext
}

describe('createUi branding background', () => {
it('defines the viewer background through a static token', () => {
expect.assertions(6)

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('--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/)
})

it('preserves the default viewer background', () => {
expect.assertions(2)

const context = createContext()
const ui = createUi()
ui.setup?.(context)

expect(context.staticConfig.ui).toEqual({ branding: {} })
expect(ui.assets).toBeUndefined()
})

it('publishes a CSS viewer background with the branding', () => {
expect.assertions(2)

const context = createContext()
const ui = createUi({ branding: { background: 'transparent' } })
ui.setup?.(context)

expect(context.staticConfig.ui).toEqual({
branding: { background: 'transparent' },
})
expect(ui.assets).toBeUndefined()
})

it('publishes color-scheme viewer backgrounds with the branding', () => {
expect.assertions(1)

const context = createContext()
const background = {
light: 'linear-gradient(white, transparent)',
dark: 'linear-gradient(#111, transparent)',
}
const ui = createUi({ branding: { background } })
ui.setup?.(context)

expect(context.staticConfig.ui).toEqual({ branding: { background } })
})

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

const ui = createUi({ viewer: false })

expect(ui.viewer).toBeUndefined()
})
})
2 changes: 2 additions & 0 deletions packages/hub-ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 }
/** 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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export interface DevframeBranding {
logo?: BrandingLogo;
wordmark?: BrandingLogo;
primaryColor?: string;
background?: string | {
light: string;
dark: string;
};
tagline?: string;
favicon?: string;
windowTitle?: string;
Expand Down
Loading