From 79da0feca9fb124490497aab19dba47d52a5e277 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 31 Aug 2026 11:42:14 +0200 Subject: [PATCH] feat(hub-ui): support embedded viewer backgrounds --- docs/content/1.guide/18.hub-initiate.md | 2 +- .../hub-ui/src/client/standalone/index.html | 3 -- packages/hub-ui/src/client/standalone/main.ts | 19 ++------ .../standalone/viewer-background.test.ts | 39 ++++++++++++++++ .../client/standalone/viewer-background.ts | 17 +++++++ .../hub-ui/src/client/state/branding.test.ts | 46 ++++++++++++++++++- packages/hub-ui/src/client/state/branding.ts | 25 +++++++--- packages/hub-ui/src/index.test.ts | 17 ++++++- packages/hub-ui/src/index.ts | 2 +- packages/hub-ui/src/types.ts | 16 ++++++- .../@devframes/hub-ui/index.snapshot.d.ts | 13 ++++-- 11 files changed, 164 insertions(+), 35 deletions(-) create mode 100644 packages/hub-ui/src/client/standalone/viewer-background.test.ts create mode 100644 packages/hub-ui/src/client/standalone/viewer-background.ts diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 7f3936d1..29095a71 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -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. diff --git a/packages/hub-ui/src/client/standalone/index.html b/packages/hub-ui/src/client/standalone/index.html index 97510332..64a1ba2b 100644 --- a/packages/hub-ui/src/client/standalone/index.html +++ b/packages/hub-ui/src/client/standalone/index.html @@ -14,9 +14,6 @@ color-scheme: dark; --devframes-viewer-background: #111; } - html.viewer-background-custom { - color-scheme: normal; - } html, body { margin: 0; diff --git a/packages/hub-ui/src/client/standalone/main.ts b/packages/hub-ui/src/client/standalone/main.ts index 5fde3835..96a34afc 100644 --- a/packages/hub-ui/src/client/standalone/main.ts +++ b/packages/hub-ui/src/client/standalone/main.ts @@ -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 @@ -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 diff --git a/packages/hub-ui/src/client/standalone/viewer-background.test.ts b/packages/hub-ui/src/client/standalone/viewer-background.test.ts new file mode 100644 index 00000000..99cd6c16 --- /dev/null +++ b/packages/hub-ui/src/client/standalone/viewer-background.test.ts @@ -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() + }) +}) diff --git a/packages/hub-ui/src/client/standalone/viewer-background.ts b/packages/hub-ui/src/client/standalone/viewer-background.ts new file mode 100644 index 00000000..b6b01f73 --- /dev/null +++ b/packages/hub-ui/src/client/standalone/viewer-background.ts @@ -0,0 +1,17 @@ +export interface ViewerBackgroundElement { + style: Pick +} + +/** 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) +} diff --git a/packages/hub-ui/src/client/state/branding.test.ts b/packages/hub-ui/src/client/state/branding.test.ts index a3ac3631..8ed3c46e 100644 --- a/packages/hub-ui/src/client/state/branding.test.ts +++ b/packages/hub-ui/src/client/state/branding.test.ts @@ -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' @@ -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() }) }) diff --git a/packages/hub-ui/src/client/state/branding.ts b/packages/hub-ui/src/client/state/branding.ts index b38d006c..5490abef 100644 --- a/packages/hub-ui/src/client/state/branding.ts +++ b/packages/hub-ui/src/client/state/branding.ts @@ -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 @@ -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 { - 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 { + 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 { + return typeof value === 'object' && value !== null && 'standalone' in value } function resolveColorSchemeValue(value: ColorSchemeValue | undefined, dark: boolean): string | undefined { diff --git a/packages/hub-ui/src/index.test.ts b/packages/hub-ui/src/index.test.ts index 74367122..f335f5a9 100644 --- a/packages/hub-ui/src/index.test.ts +++ b/packages/hub-ui/src/index.test.ts @@ -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', () => { @@ -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) diff --git a/packages/hub-ui/src/index.ts b/packages/hub-ui/src/index.ts index 9fa8b26a..f0c1e2ab 100644 --- a/packages/hub-ui/src/index.ts +++ b/packages/hub-ui/src/index.ts @@ -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 { diff --git a/packages/hub-ui/src/types.ts b/packages/hub-ui/src/types.ts index d31f8afa..1de116af 100644 --- a/packages/hub-ui/src/types.ts +++ b/packages/hub-ui/src/types.ts @@ -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 @@ -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. */ diff --git a/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts index a30eabb0..d95e523a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub-ui/index.snapshot.d.ts @@ -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; @@ -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