Skip to content
Open
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
35 changes: 35 additions & 0 deletions design/panel-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { DevframeBranding } from '../packages/hub-ui/src/types'
import { setupDevframeConnection } from 'devframe/client'

/** Apply startup branding to a built-in devframe's iframe SPA. */
export function applyPanelBranding(): () => void {
if (window.parent === window)
return () => {}

const root = document.documentElement
const previous = root.style.getPropertyValue('--devframe-primary')
const priority = root.style.getPropertyPriority('--devframe-primary')
let disposed = false
let applied = false

void setupDevframeConnection().then(({ connectionMeta }) => {
const configs = connectionMeta.configs as { ui?: { branding?: DevframeBranding } } | undefined
const color = configs?.ui?.branding?.primaryColor
if (disposed || !color || !CSS.supports('color', color))
return
root.style.setProperty('--devframe-primary', color)
applied = true
}).catch(() => {
// Connection failures leave the SPA's default palette intact.
})

return () => {
disposed = true
if (!applied)
return
if (previous)
root.style.setProperty('--devframe-primary', previous, priority)
else
root.style.removeProperty('--devframe-primary')
}
}
11 changes: 10 additions & 1 deletion design/uno.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Preset } from 'unocss'
import { fileURLToPath } from 'node:url'
import { presetAnthonyDesign } from '@antfu/design/unocss'
import { presetAnthonyDesign, resolvePrimary } from '@antfu/design/unocss'
import {
defineConfig,
presetIcons,
Expand Down Expand Up @@ -60,7 +60,16 @@ export function createDesignConfig(options: CreateDesignConfigOptions = {}) {
* shared border color (matching `border-base`) for unqualified borders.
*/
preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }],
theme: {
// Stable palette for status marks and preview content that intentionally
// keeps the default accent when the surrounding panel adopts a theme.
colors: { devframe: resolvePrimary('#3a6a45') },
},
shortcuts: {
/** Fixed semantic colors stay independent of the panel's primary accent. */
'color-status-positive': 'color-devframe-600 dark:color-devframe-300',
'color-preview-accent': 'color-devframe-600 dark:color-devframe-300',
'bg-preview-accent': 'bg-devframe',
/** Fixed navbar height, shared by every surface's top nav. */
'h-nav': 'h-10',
/** Named z-index layers, shared across every surface. */
Expand Down
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. These flat forms apply everywhere. Use `{ standalone, iframe? }` to specialize the framed viewer; an omitted `iframe` value falls back to `standalone`.
- **`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`. Built-in devframe panels read `branding.primaryColor` from the existing connection metadata at startup and map it to CSS primary tokens. Standalone SPAs keep their default accent; status and preview palettes remain independent. Reload panels after changing startup branding.
- **`dockPreferences`** tunes the dock rail: `categoryOrder`, floating-dock `maxVisibleItems`, first-run `defaultMode` (`'float'`/`'edge'`) and `defaultPosition`.
- **`embeddedVisibility`** sets the floating dock's reveal policy:
- `'normal'` (default): shows immediately.
Expand Down
8 changes: 8 additions & 0 deletions plugins/a11y/app/main.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
/// <reference types="vite/client" />

/* @refresh reload */

import { render } from 'solid-js/web'
import { applyPanelBranding } from '../../../design/panel-theme'
import { App } from './app.tsx'
import 'virtual:uno.css'
import '@antfu/design/styles.css'
import './styles.css'
import '../../../packages/hub-ui/src/client/primary-ramp.css'

// Shared design tokens flip on the `.dark` class; mirror the OS preference onto
// <html> (the other devframe plugins follow the same approach).
Expand All @@ -20,3 +25,6 @@ if (!root)
throw new Error('#app mount node missing from index.html')

render(() => <App />, root)

const stopPanelTheme = applyPanelBranding()
import.meta.hot?.dispose(stopPanelTheme)
5 changes: 5 additions & 0 deletions plugins/assets/app/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createApp } from 'vue'
import { applyPanelBranding } from '../../../design/panel-theme'
import App from './app/App.vue'
import 'virtual:uno.css'
import 'floating-vue/dist/style.css'
import '@antfu/design/styles.css'
import '../../../packages/hub-ui/src/client/primary-ramp.css'

// Shared design tokens flip on the `.dark` class; mirror the OS preference
// onto <html> (the built-in devframe plugins all follow this approach).
Expand All @@ -15,3 +17,6 @@ applyScheme(mq.matches)
mq.addEventListener('change', e => applyScheme(e.matches))

createApp(App).mount('#app')

const stopPanelTheme = applyPanelBranding()
import.meta.hot?.dispose(stopPanelTheme)
7 changes: 7 additions & 0 deletions plugins/code-server/app/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/// <reference types="vite/client" />

import { createApp } from 'vue'
import { applyPanelBranding } from '../../../design/panel-theme'
import App from './App.vue'
import 'virtual:uno.css'
import '@antfu/design/styles.css'
import './style.css'
import '../../../packages/hub-ui/src/client/primary-ramp.css'

// The shared design tokens flip on the `.dark` class; mirror the OS preference
// onto <html> (the other devframe plugins follow the same approach).
Expand All @@ -15,3 +19,6 @@ applyScheme(mq.matches)
mq.addEventListener('change', e => applyScheme(e.matches))

createApp(App).mount('#app')

const stopPanelTheme = applyPanelBranding()
import.meta.hot?.dispose(stopPanelTheme)
2 changes: 1 addition & 1 deletion plugins/data-inspector/app/components/QueryEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ ul.discovery-view-editor-hints-popup {
--at-apply: 'bg-#8882 color-active';
}
.discovery-view-editor-hint .match {
--at-apply: 'color-primary-700 dark:color-primary-300';
--at-apply: 'color-active';
font-weight: 600;
}
.discovery-view-editor-hint::before {
Expand Down
7 changes: 7 additions & 0 deletions plugins/data-inspector/app/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
/// <reference types="vite/client" />

import { createApp } from 'vue'
import { applyPanelBranding } from '../../../design/panel-theme'
import App from './App.vue'
import 'virtual:uno.css'
// floating-vue's base popper/transition structure, then @antfu/design's themed
Expand All @@ -8,5 +11,9 @@ import 'floating-vue/dist/style.css'
import '@antfu/design/styles/floating-vue.css'
import '@antfu/design/styles.css'
import './style.css'
import '../../../packages/hub-ui/src/client/primary-ramp.css'

createApp(App).mount('#app')

const stopPanelTheme = applyPanelBranding()
import.meta.hot?.dispose(stopPanelTheme)
1 change: 1 addition & 0 deletions plugins/git/app/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import './globals.css'
import '@antfu/design/styles.css'
import '../../../../packages/hub-ui/src/client/primary-ramp.css'

export const metadata: Metadata = {
title: 'Git Dashboard',
Expand Down
3 changes: 3 additions & 0 deletions plugins/git/app/components/theme.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { useEffect, useState } from 'react'
import { applyPanelBranding } from '../../../../design/panel-theme'

export type Theme = 'light' | 'dark'

Expand Down Expand Up @@ -28,6 +29,8 @@ function systemTheme(): Theme {
export function useTheme() {
const [theme, setTheme] = useState<Theme>('dark')

useEffect(() => applyPanelBranding(), [])

useEffect(() => {
setTheme(readStored() ?? systemTheme())
}, [])
Expand Down
4 changes: 2 additions & 2 deletions plugins/git/app/components/ui/status-mark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ const STATUS_COLOR: Record<FileStatusCode, string> = {
'added': 'text-success',
'deleted': 'text-error',
'modified': 'text-warning',
'renamed': 'color-active',
'copied': 'color-active',
'renamed': 'color-status-positive',
'copied': 'color-status-positive',
'type-changed': 'text-warning',
'unmerged': 'text-error',
'unknown': 'color-muted',
Expand Down
7 changes: 7 additions & 0 deletions plugins/inspect/app/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/// <reference types="vite/client" />

import { createApp } from 'vue'
import { applyPanelBranding } from '../../../design/panel-theme'
import App from './App.vue'
import 'virtual:uno.css'
import 'floating-vue/dist/style.css'
import '@antfu/design/styles.css'
import './style.css'
import '../../../packages/hub-ui/src/client/primary-ramp.css'

// The shared design tokens flip on the `.dark` class; mirror the OS preference
// onto <html> (the other devframe plugins follow the same approach).
Expand All @@ -16,3 +20,6 @@ applyScheme(mq.matches)
mq.addEventListener('change', e => applyScheme(e.matches))

createApp(App).mount('#app')

const stopPanelTheme = applyPanelBranding()
import.meta.hot?.dispose(stopPanelTheme)
7 changes: 7 additions & 0 deletions plugins/messages/app/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/// <reference types="vite/client" />

import { applyPanelBranding } from '../../../design/panel-theme'
import { mountMessages } from './client/index'
import '../../../packages/hub-ui/src/client/primary-ramp.css'

// The shared design tokens flip on the `.dark` class; mirror the OS preference
// onto <html> (the other devframe plugins follow the same approach). The
Expand All @@ -19,3 +23,6 @@ if (!app)
mountMessages(app).catch((error) => {
app.textContent = `Failed to connect: ${error instanceof Error ? error.message : String(error)}`
})

const stopPanelTheme = applyPanelBranding()
import.meta.hot?.dispose(stopPanelTheme)
2 changes: 1 addition & 1 deletion plugins/og/app/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ onMounted(() => viewer.inspect())
<div v-else class="grid min-h-full gap4 p4 xl:grid-cols-[minmax(0,1fr)_minmax(28rem,0.85fr)]">
<div class="min-w-0 flex flex-col gap4">
<div class="flex flex-wrap items-center gap-2 color-muted text-xs">
<span class="i-ph-check-circle-duotone color-active" />
<span class="i-ph-check-circle-duotone color-status-positive" />
<a :href="viewer.snapshot.value.url" target="_blank" rel="noreferrer" class="min-w-0 truncate font-mono hover:color-active" :title="viewer.snapshot.value.url">
{{ viewer.snapshot.value.url }}
</a>
Expand Down
6 changes: 3 additions & 3 deletions plugins/og/app/app/components/previews/TelegramPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-di

<template>
<article class="w-full max-w-105 border border-base rounded-4 rounded-bl-sm bg-base p3 shadow-sm">
<a :href="card.url" target="_blank" rel="noreferrer" class="block truncate color-active text-sm underline">{{ card.url }}</a>
<a :href="card.url" target="_blank" rel="noreferrer" class="block truncate color-preview-accent text-sm underline">{{ card.url }}</a>
<div class="mt2 grid grid-cols-[2px_1fr] gap-2">
<div class="rounded bg-primary op70" />
<div class="rounded bg-preview-accent op70" />
<div class="min-w-0">
<PreviewImage :src="card.image" :alt="card.imageAlt" class="mb2 aspect-1.91/1 w-full rounded" />
<div class="color-active text-sm font-semibold">
<div class="color-preview-accent text-sm font-semibold">
{{ card.hostname }}
</div>
<h3 class="my1 text-sm font-semibold">
Expand Down
5 changes: 5 additions & 0 deletions plugins/og/app/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createApp } from 'vue'
import { applyPanelBranding } from '../../../design/panel-theme'
import App from './app/app.vue'
import '../../../packages/hub-ui/src/client/primary-ramp.css'

const media = window.matchMedia('(prefers-color-scheme: dark)')
function applyScheme(dark: boolean): void {
Expand All @@ -10,3 +12,6 @@ applyScheme(media.matches)
media.addEventListener('change', event => applyScheme(event.matches))

createApp(App).mount('#app')

const stopPanelTheme = applyPanelBranding()
import.meta.hot?.dispose(stopPanelTheme)
7 changes: 7 additions & 0 deletions plugins/terminals/app/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/// <reference types="vite/client" />

import { applyPanelBranding } from '../../../design/panel-theme'
import { mountTerminals } from './client/index'
import '../../../packages/hub-ui/src/client/primary-ramp.css'

const app = document.getElementById('app')
if (!app)
Expand All @@ -7,3 +11,6 @@ if (!app)
mountTerminals(app).catch((error) => {
app.textContent = `Failed to connect: ${error instanceof Error ? error.message : String(error)}`
})

const stopPanelTheme = applyPanelBranding()
import.meta.hot?.dispose(stopPanelTheme)
74 changes: 74 additions & 0 deletions tests/panel-branding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { applyPanelBranding } from '../design/panel-theme'

const setup = vi.hoisted(() => vi.fn())
vi.mock('devframe/client', () => ({ setupDevframeConnection: setup }))

let values: Map<string, string>
let cleanup: (() => void) | undefined

beforeEach(() => {
values = new Map()
setup.mockReset()
vi.stubGlobal('window', { parent: {} })
vi.stubGlobal('CSS', { supports: (_: string, color: string) => color === '#6b84fd' })
vi.stubGlobal('document', {
documentElement: {
style: {
getPropertyValue: (name: string) => values.get(name) ?? '',
getPropertyPriority: () => '',
setProperty: (name: string, value: string) => values.set(name, value),
removeProperty: (name: string) => values.delete(name),
},
},
})
})

afterEach(() => {
cleanup?.()
cleanup = undefined
vi.unstubAllGlobals()
})

it('keeps standalone SPAs independent of hub branding', () => {
window.parent = window
cleanup = applyPanelBranding()
expect(setup).not.toHaveBeenCalled()
expect(values.size).toBe(0)
})

it('maps startup metadata to one CSS input and restores the previous palette on cleanup', async () => {
values.set('--devframe-primary', 'red')
setup.mockResolvedValue({ connectionMeta: { configs: { ui: { branding: { primaryColor: '#6b84fd' } } } } })
cleanup = applyPanelBranding()
await vi.waitFor(() => expect(values.get('--devframe-primary')).toBe('#6b84fd'))
expect([...values.keys()]).toEqual(['--devframe-primary'])
cleanup()
expect(values.get('--devframe-primary')).toBe('red')
})

it.each([undefined, '', 'invalid-color'])('keeps the default palette for missing or invalid branding (%s)', async (primaryColor) => {
setup.mockResolvedValue({ connectionMeta: { configs: { ui: { branding: { primaryColor } } } } })
cleanup = applyPanelBranding()
await setup.mock.results[0].value
expect(values.size).toBe(0)
})

it('keeps the default palette when connection metadata is unavailable', async () => {
setup.mockRejectedValue(new Error('Unavailable'))
cleanup = applyPanelBranding()
await new Promise(resolve => setTimeout(resolve, 0))
expect(values.size).toBe(0)
})

it('ignores metadata that arrives after the SPA has been disposed', async () => {
let resolve!: (value: unknown) => void
setup.mockReturnValue(new Promise((done) => {
resolve = done
}))
cleanup = applyPanelBranding()
cleanup()
resolve({ connectionMeta: { configs: { ui: { branding: { primaryColor: '#6b84fd' } } } } })
await setup.mock.results[0].value
expect(values.size).toBe(0)
})
Loading
Loading