diff --git a/design/panel-theme.ts b/design/panel-theme.ts new file mode 100644 index 000000000..9bf4100b0 --- /dev/null +++ b/design/panel-theme.ts @@ -0,0 +1,50 @@ +import { watchDevframeTheme } from 'devframe/client' + +// Match the reference dock's primary ramp. Only accent tokens change; semantic +// success/error/warning colors and each SPA's chart palettes stay independent. +const primaryStops = { + DEFAULT: 0, + 50: 95, + 100: 90, + 200: 75, + 600: 9, + 500: 18, + 400: 34, + 300: 54, + 700: -8, + 800: -25, + 900: -45, + 950: -70, +} as const + +/** Opt a built-in devframe SPA into the hub UI provider's primary accent. */ +export function syncPanelTheme(): () => void { + const style = document.documentElement.style + const previous = Object.keys(primaryStops).map((stop) => { + const name = `--colors-primary-${stop}` + return { name, value: style.getPropertyValue(name), priority: style.getPropertyPriority(name) } + }) + function restore() { + for (const { name, value, priority } of previous) { + if (value) + style.setProperty(name, value, priority) + else + style.removeProperty(name) + } + } + const stop = watchDevframeTheme(({ primaryColor }) => { + if (!primaryColor || !CSS.supports('color', primaryColor)) { + restore() + return + } + for (const [stop, white] of Object.entries(primaryStops)) { + style.setProperty(`--colors-primary-${stop}`, white + ? `color-mix(in oklab, ${primaryColor}, ${white > 0 ? 'white' : 'black'} ${Math.abs(white)}%)` + : primaryColor) + } + }) + return () => { + stop() + restore() + } +} diff --git a/design/uno.config.ts b/design/uno.config.ts index 297cad0a4..2c2f6e547 100644 --- a/design/uno.config.ts +++ b/design/uno.config.ts @@ -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, @@ -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. */ diff --git a/docs/content/1.guide/11.client.md b/docs/content/1.guide/11.client.md index c26e6a53e..65a4eb8a4 100644 --- a/docs/content/1.guide/11.client.md +++ b/docs/content/1.guide/11.client.md @@ -333,3 +333,35 @@ async function reconnect() { ``` In a hub, a hub UI provider reads this status from [`context.connection`](/guide/client-context#the-client-context). + +## Panel accent + +A SPA can adopt its hub UI provider's primary accent with `watchDevframeTheme()`. Map the hint to your own UI token and retain separate tokens for brand graphics, status colors, and charts: + +```ts +import { watchDevframeTheme } from 'devframe/client' + +const stopTheme = watchDevframeTheme(({ primaryColor }) => { + const style = document.documentElement.style + if (primaryColor && CSS.supports('color', primaryColor)) + style.setProperty('--panel-accent', primaryColor) + else + style.removeProperty('--panel-accent') +}) + +// Call stopTheme() when the SPA unmounts. +``` + +Use `var(--panel-accent, #3a6a45)` in the SPA's accent styles. A standalone SPA keeps its fallback. Subscription is independent of the RPC connection and works with static builds. The callback receives presentation hints from the direct embedding window; each SPA chooses the UI tokens it changes. + +`@devframes/hub-ui` offers its `branding.primaryColor` to panels automatically. A custom hub UI provider can use the same protocol: + +```ts +import { provideDevframeTheme } from 'devframe/client' + +const theme = provideDevframeTheme(iframe, { primaryColor: '#6b84fd' }) +theme.update({ primaryColor: '#8250df' }) +// Call theme.dispose() when the iframe view unmounts. +``` + +The provider supports both startup orders, reloads, and remounting a preserved iframe. The [Panel theme reference](/references/browser-api#panel-theme) lists the API and origin contract. diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 96fb817f8..d9c211672 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -127,3 +127,4 @@ Pushed over each panel's [in-page channel](/guide/in-page-channel) port; the pai |---|---|---| | `devframe:remote-assets-error` | the remote-assets fallback page, to `window.parent` | The failed package/version/reason, so an embedding hub UI provider can replace the 502 page. | | `devframe:in-page-channel` | both [in-page channel](/guide/in-page-channel) endpoints, across window boundaries | The versioned handshake envelope (panel hello, page-script port grant). | +| `devframe:theme` | the embedding window and its panel iframe | Optional accent handshake: `available`, `request`, and `update` with a `DevframeTheme`. | diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index 38dc018e9..2bc36e756 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -46,6 +46,20 @@ The values of `rpc.status`: [Handling connection and auth errors](/guide/client# | `disconnected` | Socket closed (dropped mid-session or never opened). | | `error` | Fatal: the socket errored or connection meta couldn't load. | +## Panel theme + +Optional presentation hints from the embedding window: [Panel accent](/guide/client#panel-accent). All exports come from `devframe/client`. + +| API | Contract | +|-----|----------| +| `DevframeTheme.primaryColor` | Optional CSS color. Omitted means use the SPA's default accent. | +| `watchDevframeTheme(onChange)` | Opt the panel into updates from its direct parent window. Returns a listener cleanup function. Standalone SPAs and SSR return a no-op cleanup. | +| `provideDevframeTheme(iframe, theme)` | Offer hints to one iframe; only an opted-in panel receives theme data. Returns a `DevframeThemeProvider`. | +| `provider.update(theme)` | Replace the hints; `update({})` clears the accent override. | +| `provider.dispose()` | Remove the provider's message and load listeners. | + +The protocol supports same-origin and cross-origin iframe documents with a non-opaque origin. Each side checks the sending window; updates target the requesting document's exact origin. A newly loaded iframe document opts in again. The subscription trusts its direct embedding window for presentation hints; the SPA controls how it applies them. + ## In-page channel endpoints The browser-only endpoint methods of the [in-page channel](/guide/in-page-channel). `emit()` sends to the opposite endpoint; `on()` handles events arriving from that endpoint. diff --git a/docs/content/8.references/index.md b/docs/content/8.references/index.md index bc01b8fa6..5252db962 100644 --- a/docs/content/8.references/index.md +++ b/docs/content/8.references/index.md @@ -11,7 +11,7 @@ Lookup pages the guides link into: - [When Clauses](/references/when-clauses): the contexts and operators that gate docks, commands, and custom UI. - [Events Reference](/references/events): every event, broadcast, shared-state key, and channel name, by direction and reach. - [Node-Side API](/references/node-api): `DevframeDefinition` fields, CLI options, storage scopes, RPC function types, broadcast options, streaming lifecycle, remote assets, the `ctx.services` host and wire-service fields, diagnostics prefixes, and the auth surface. -- [Browser-Side API](/references/browser-api): `connectDevframe` options, RPC client events, connection statuses, and in-page channel error codes. +- [Browser-Side API](/references/browser-api): `connectDevframe` options, RPC client events, connection statuses, panel theme APIs, and in-page channel error codes. - [Hub API](/references/hub-api): hub subsystems, launcher fields, duplication strategies, dock categories, the hub UI protocol, the namespace routes, the client runtime, the client context, and dock entry types. - [Utilities](/references/utilities): the small, stable helpers under `devframe/utils/*`, bundled into `devframe`. - [Interactive Auth](/references/interactive-auth): the OTP auth recipe: handshake, resolver gate, connect-time trust, banner. diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index 1add93bc5..99f5a68f6 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -9,6 +9,7 @@ export * from './rpc-streaming' export { resolveWsUrl, type WsUrlLocation } from './rpc-ws' export * from './scope' export * from './settings' +export * from './theme' export * from './webmcp' export const connectDevframe = getDevframeRpcClient diff --git a/packages/devframe/src/client/theme.test.ts b/packages/devframe/src/client/theme.test.ts new file mode 100644 index 000000000..1058c4c39 --- /dev/null +++ b/packages/devframe/src/client/theme.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DEVFRAME_EVENTS } from '../events' +import { provideDevframeTheme, watchDevframeTheme } from './theme' + +const channel = DEVFRAME_EVENTS.postMessage.theme + +function fixture() { + const parent = new EventTarget() as Window + const panel = new EventTarget() as Window + const origin = 'https://panel.example' + const parentOrigin = 'https://hub.example' + const queue: (() => void)[] = [] + const send = (target: EventTarget, data: unknown, source: EventTarget, origin: string) => { + target.dispatchEvent(Object.assign(new Event('message'), { data, source, origin })) + } + Object.assign(parent, { + postMessage: (data: unknown) => queue.push(() => send(parent, data, panel, origin)), + }) + Object.assign(panel, { + parent, + postMessage: (data: unknown, targetOrigin: string) => queue.push(() => { + if (targetOrigin === '*' || targetOrigin === origin) + send(panel, data, parent, parentOrigin) + }), + }) + // Model both browsing contexts and queued postMessage delivery without a DOM. + const iframe = Object.assign(new EventTarget(), { + contentWindow: panel, + ownerDocument: { defaultView: parent } as Document, + }) as HTMLIFrameElement + vi.stubGlobal('window', panel) + return { + iframe, + panel, + parent, + send, + flush() { + while (queue.length) + queue.shift()!() + }, + } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('panel theme protocol', () => { + it.each(['panel', 'provider'])('connects cross-origin when %s boots first', (first) => { + const f = fixture() + const changed = vi.fn() + const startPanel = () => watchDevframeTheme(changed) + const startProvider = () => provideDevframeTheme(f.iframe, { primaryColor: '#6b84fd' }) + if (first === 'panel') { + startPanel() + f.flush() + startProvider() + } + else { + startProvider() + f.flush() + startPanel() + } + f.flush() + expect(changed).toHaveBeenLastCalledWith({ primaryColor: '#6b84fd' }) + }) + + it('updates and clears the accent without changing other theme state', () => { + const f = fixture() + const provider = provideDevframeTheme(f.iframe, { primaryColor: '#6b84fd' }) + const changed = vi.fn() + watchDevframeTheme(changed) + f.flush() + provider.update({ primaryColor: 'rebeccapurple' }) + f.flush() + expect(changed).toHaveBeenLastCalledWith({ primaryColor: 'rebeccapurple' }) + provider.update({}) + f.flush() + expect(changed).toHaveBeenLastCalledWith({ primaryColor: undefined }) + }) + + it('offers no theme data until the iframe opts in', () => { + const f = fixture() + const received: unknown[] = [] + f.panel.addEventListener('message', event => received.push((event as MessageEvent).data)) + const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' }) + provider.update({ primaryColor: 'red' }) + f.flush() + expect(received).toEqual([{ channel, type: 'available' }]) + }) + + it('requires a new document to opt in after navigation', () => { + const f = fixture() + const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' }) + const changed = vi.fn() + const stop = watchDevframeTheme(changed) + f.flush() + stop() + f.iframe.dispatchEvent(new Event('load')) + provider.update({ primaryColor: 'red' }) + f.flush() + changed.mockClear() + watchDevframeTheme(changed) + f.flush() + expect(changed).toHaveBeenLastCalledWith({ primaryColor: 'red' }) + }) + + it('rejects theme updates from sibling windows and malformed messages', () => { + const f = fixture() + const changed = vi.fn() + watchDevframeTheme(changed) + f.send(f.panel, { channel, type: 'update', theme: { primaryColor: 'red' } }, new EventTarget(), 'https://hub.example') + for (const data of [null, 'blue', { channel, type: 'update', theme: null }, { channel, type: 'update', theme: { primaryColor: 42 } }]) + f.send(f.panel, data, f.parent, 'https://hub.example') + expect(changed).not.toHaveBeenCalled() + }) + + it('rejects requests from unrelated windows and opaque origins', () => { + const f = fixture() + const received: unknown[] = [] + f.panel.addEventListener('message', event => received.push((event as MessageEvent).data)) + provideDevframeTheme(f.iframe, { primaryColor: 'blue' }) + f.flush() + received.length = 0 + f.send(f.parent, { channel, type: 'request' }, new EventTarget(), 'https://panel.example') + f.send(f.parent, { channel, type: 'request' }, f.panel, 'null') + f.flush() + expect(received).toEqual([]) + }) + + it('cleans up both ends and reconnects a preserved iframe to a new provider', () => { + const f = fixture() + const changed = vi.fn() + const stop = watchDevframeTheme(changed) + const provider = provideDevframeTheme(f.iframe, { primaryColor: 'blue' }) + f.flush() + provider.dispose() + changed.mockClear() + provider.update({ primaryColor: 'red' }) + f.iframe.dispatchEvent(new Event('load')) + f.flush() + expect(changed).not.toHaveBeenCalled() + const next = provideDevframeTheme(f.iframe, {}) + f.flush() + expect(changed).toHaveBeenLastCalledWith({ primaryColor: undefined }) + stop() + changed.mockClear() + next.update({ primaryColor: 'red' }) + f.flush() + expect(changed).not.toHaveBeenCalled() + }) + + it('leaves standalone SPAs and SSR untouched', () => { + const changed = vi.fn() + vi.stubGlobal('window', undefined) + watchDevframeTheme(changed)() + const standalone = { parent: undefined as unknown } + standalone.parent = standalone + vi.stubGlobal('window', standalone) + watchDevframeTheme(changed)() + expect(changed).not.toHaveBeenCalled() + }) +}) diff --git a/packages/devframe/src/client/theme.ts b/packages/devframe/src/client/theme.ts new file mode 100644 index 000000000..b0f79443f --- /dev/null +++ b/packages/devframe/src/client/theme.ts @@ -0,0 +1,104 @@ +import { DEVFRAME_EVENTS } from '../events' + +/** Optional presentation hints supplied by the hub UI provider to a panel. */ +export interface DevframeTheme { + /** CSS color for the panel's primary UI accent. Omitted to use the SPA's default. */ + primaryColor?: string +} + +export interface DevframeThemeProvider { + /** Replace the presentation hints, including clearing a previous accent. */ + update: (theme: DevframeTheme) => void + /** Remove the message and iframe-load listeners. */ + dispose: () => void +} + +const channel = DEVFRAME_EVENTS.postMessage.theme + +function isThemeMessage(data: unknown): data is { channel: typeof channel, type: string, theme?: unknown } { + return typeof data === 'object' && data !== null + && 'channel' in data && data.channel === channel + && 'type' in data && typeof data.type === 'string' +} + +function isTheme(theme: unknown): theme is DevframeTheme { + return typeof theme === 'object' && theme !== null + && (!('primaryColor' in theme) || theme.primaryColor === undefined || typeof theme.primaryColor === 'string') +} + +/** + * Opt a panel into its embedding window's accent hints. Maps no styles itself: + * the SPA chooses which UI tokens to update. Returns a listener cleanup. + * Standalone SPAs and server-side rendering keep their own theme. + */ +export function watchDevframeTheme(onChange: (theme: DevframeTheme) => void): () => void { + if (typeof window === 'undefined' || window.parent === window) + return () => {} + + const parent = window.parent + function request() { + // The request contains no application data. The embedding window may be + // cross-origin; only that exact window can answer with presentation hints. + parent.postMessage({ channel, type: 'request' }, '*') + } + function onMessage(event: MessageEvent) { + if (event.source !== parent || !isThemeMessage(event.data)) + return + if (event.data.type === 'available') + request() + else if (event.data.type === 'update' && isTheme(event.data.theme)) + onChange({ primaryColor: event.data.theme.primaryColor }) + } + + window.addEventListener('message', onMessage) + request() + return () => window.removeEventListener('message', onMessage) +} + +/** + * Offer accent hints to one iframe. Only a panel calling watchDevframeTheme() + * receives updates; the hub UI provider never writes into the iframe's DOM. + * The ready handshake supports either boot order, reloads and cross-origin SPAs. + */ +export function provideDevframeTheme(iframe: HTMLIFrameElement, initial: DevframeTheme): DevframeThemeProvider { + const ownerWindow = iframe.ownerDocument.defaultView! + let theme = { primaryColor: initial.primaryColor } + let subscribedOrigin: string | undefined + + function announce() { + iframe.contentWindow?.postMessage({ channel, type: 'available' }, '*') + } + function publish() { + if (subscribedOrigin !== undefined) + iframe.contentWindow?.postMessage({ channel, type: 'update', theme }, subscribedOrigin) + } + function onMessage(event: MessageEvent) { + if (!iframe.contentWindow || event.source !== iframe.contentWindow || !isThemeMessage(event.data)) + return + if (event.data.type !== 'request' || event.origin === 'null') + return + subscribedOrigin = event.origin + publish() + } + function onLoad() { + // A new document must opt in again, including after an address-bar navigation. + subscribedOrigin = undefined + announce() + } + + ownerWindow.addEventListener('message', onMessage) + iframe.addEventListener('load', onLoad) + announce() + + return { + update(next) { + theme = { primaryColor: next.primaryColor } + publish() + }, + dispose() { + ownerWindow.removeEventListener('message', onMessage) + iframe.removeEventListener('load', onLoad) + subscribedOrigin = undefined + }, + } +} diff --git a/packages/devframe/src/events.ts b/packages/devframe/src/events.ts index ebc958d67..531d2ab2e 100644 --- a/packages/devframe/src/events.ts +++ b/packages/devframe/src/events.ts @@ -70,6 +70,7 @@ export const DEVFRAME_EVENTS = { }, /** `postMessage` channels the runtime posts across window boundaries. */ postMessage: { + theme: 'devframe:theme', remoteAssetsError: 'devframe:remote-assets-error', inPageChannel: 'devframe:in-page-channel', }, diff --git a/packages/hub-ui/src/client/components/views/ViewIframe.vue b/packages/hub-ui/src/client/components/views/ViewIframe.vue index cd63ddaed..592b925b3 100644 --- a/packages/hub-ui/src/client/components/views/ViewIframe.vue +++ b/packages/hub-ui/src/client/components/views/ViewIframe.vue @@ -6,7 +6,9 @@ import type { IframePanes } from 'iframe-pane' import type { CSSProperties } from 'vue' import { stripRemoteConnectionFromUrl, watchFrameLocation } from '@devframes/hub/client' import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE } from '@devframes/hub/constants' +import { provideDevframeTheme } from 'devframe/client' import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue' +import { useBranding } from '../../state/branding' import { useSettings } from '../../state/settings-defaults' import ViewAssetsError from './ViewAssetsError.vue' import ViewIframeLoading from './ViewIframeLoading.vue' @@ -19,6 +21,7 @@ const props = defineProps<{ }>() const settings = useSettings(props.context) +const branding = useBranding() const isEdgeMode = computed(() => props.context.panel.store.mode === 'edge') const addressBarControls = computed(() => typeof props.entry.addressBar === 'object' ? props.entry.addressBar : undefined) const showAddressBar = computed(() => settings.value.showIframeAddressBar || Boolean(props.entry.addressBar)) @@ -211,6 +214,7 @@ function openExternally() { let mountedTarget: HTMLDivElement | null = null let onIframeLoad: (() => void) | undefined let stopLocationWatch: (() => void) | undefined +let disposeTheme: (() => void) | undefined onMounted(() => { const existed = props.panes.has(paneKey.value) @@ -232,6 +236,9 @@ onMounted(() => { style: { boxShadow: 'none', outline: 'none' }, }) const iframe = pane.iframe + const themeProvider = provideDevframeTheme(iframe, { primaryColor: branding.value.primaryColor }) + disposeTheme = themeProvider.dispose + watchEffect(() => themeProvider.update({ primaryColor: branding.value.primaryColor })) // Follow the frame wherever it goes: a document load, but also an SPA // router's `pushState`/`replaceState` and back/forward, none of which fire @@ -315,6 +322,7 @@ onMounted(() => { }) onUnmounted(() => { + disposeTheme?.() window.removeEventListener('message', onWindowMessage) const pane = props.panes.get(paneKey.value) if (pane && onIframeLoad) diff --git a/plugins/a11y/app/main.tsx b/plugins/a11y/app/main.tsx index 4463c0610..7c5738c00 100644 --- a/plugins/a11y/app/main.tsx +++ b/plugins/a11y/app/main.tsx @@ -1,5 +1,9 @@ +/// + /* @refresh reload */ + import { render } from 'solid-js/web' +import { syncPanelTheme } from '../../../design/panel-theme' import { App } from './app.tsx' import 'virtual:uno.css' import '@antfu/design/styles.css' @@ -20,3 +24,6 @@ if (!root) throw new Error('#app mount node missing from index.html') render(() => , root) + +const stopPanelTheme = syncPanelTheme() +import.meta.hot?.dispose(stopPanelTheme) diff --git a/plugins/assets/app/main.ts b/plugins/assets/app/main.ts index 63d704e3f..bc9e3ddde 100644 --- a/plugins/assets/app/main.ts +++ b/plugins/assets/app/main.ts @@ -1,4 +1,5 @@ import { createApp } from 'vue' +import { syncPanelTheme } from '../../../design/panel-theme' import App from './app/App.vue' import 'virtual:uno.css' import 'floating-vue/dist/style.css' @@ -15,3 +16,6 @@ applyScheme(mq.matches) mq.addEventListener('change', e => applyScheme(e.matches)) createApp(App).mount('#app') + +const stopPanelTheme = syncPanelTheme() +import.meta.hot?.dispose(stopPanelTheme) diff --git a/plugins/code-server/app/main.ts b/plugins/code-server/app/main.ts index e6b23a3f3..7d7cceb91 100644 --- a/plugins/code-server/app/main.ts +++ b/plugins/code-server/app/main.ts @@ -1,4 +1,7 @@ +/// + import { createApp } from 'vue' +import { syncPanelTheme } from '../../../design/panel-theme' import App from './App.vue' import 'virtual:uno.css' import '@antfu/design/styles.css' @@ -15,3 +18,6 @@ applyScheme(mq.matches) mq.addEventListener('change', e => applyScheme(e.matches)) createApp(App).mount('#app') + +const stopPanelTheme = syncPanelTheme() +import.meta.hot?.dispose(stopPanelTheme) diff --git a/plugins/data-inspector/app/main.ts b/plugins/data-inspector/app/main.ts index 6b3cb9934..3bc5fb32a 100644 --- a/plugins/data-inspector/app/main.ts +++ b/plugins/data-inspector/app/main.ts @@ -1,4 +1,7 @@ +/// + import { createApp } from 'vue' +import { syncPanelTheme } 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 @@ -10,3 +13,6 @@ import '@antfu/design/styles.css' import './style.css' createApp(App).mount('#app') + +const stopPanelTheme = syncPanelTheme() +import.meta.hot?.dispose(stopPanelTheme) diff --git a/plugins/git/app/components/theme.ts b/plugins/git/app/components/theme.ts index 39f6fa854..daeeaf2b1 100644 --- a/plugins/git/app/components/theme.ts +++ b/plugins/git/app/components/theme.ts @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import { syncPanelTheme } from '../../../../design/panel-theme' export type Theme = 'light' | 'dark' @@ -28,6 +29,8 @@ function systemTheme(): Theme { export function useTheme() { const [theme, setTheme] = useState('dark') + useEffect(() => syncPanelTheme(), []) + useEffect(() => { setTheme(readStored() ?? systemTheme()) }, []) diff --git a/plugins/git/app/components/ui/status-mark.tsx b/plugins/git/app/components/ui/status-mark.tsx index 0bdee2ac0..08db8e726 100644 --- a/plugins/git/app/components/ui/status-mark.tsx +++ b/plugins/git/app/components/ui/status-mark.tsx @@ -20,8 +20,8 @@ const STATUS_COLOR: Record = { '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', diff --git a/plugins/inspect/app/main.ts b/plugins/inspect/app/main.ts index 1894611c0..78608d82e 100644 --- a/plugins/inspect/app/main.ts +++ b/plugins/inspect/app/main.ts @@ -1,4 +1,7 @@ +/// + import { createApp } from 'vue' +import { syncPanelTheme } from '../../../design/panel-theme' import App from './App.vue' import 'virtual:uno.css' import 'floating-vue/dist/style.css' @@ -16,3 +19,6 @@ applyScheme(mq.matches) mq.addEventListener('change', e => applyScheme(e.matches)) createApp(App).mount('#app') + +const stopPanelTheme = syncPanelTheme() +import.meta.hot?.dispose(stopPanelTheme) diff --git a/plugins/messages/app/main.ts b/plugins/messages/app/main.ts index 1a314e3d0..8c0218c97 100644 --- a/plugins/messages/app/main.ts +++ b/plugins/messages/app/main.ts @@ -1,3 +1,6 @@ +/// + +import { syncPanelTheme } from '../../../design/panel-theme' import { mountMessages } from './client/index' // The shared design tokens flip on the `.dark` class; mirror the OS preference @@ -19,3 +22,6 @@ if (!app) mountMessages(app).catch((error) => { app.textContent = `Failed to connect: ${error instanceof Error ? error.message : String(error)}` }) + +const stopPanelTheme = syncPanelTheme() +import.meta.hot?.dispose(stopPanelTheme) diff --git a/plugins/og/app/app/app.vue b/plugins/og/app/app/app.vue index 0f10f9965..3f2ca6dcf 100644 --- a/plugins/og/app/app/app.vue +++ b/plugins/og/app/app/app.vue @@ -49,7 +49,7 @@ onMounted(() => viewer.inspect())
- + {{ viewer.snapshot.value.url }} diff --git a/plugins/og/app/app/components/previews/TelegramPreview.vue b/plugins/og/app/app/components/previews/TelegramPreview.vue index 107b4ac84..ee6ad7999 100644 --- a/plugins/og/app/app/components/previews/TelegramPreview.vue +++ b/plugins/og/app/app/components/previews/TelegramPreview.vue @@ -11,12 +11,12 @@ const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-di