From aa20df81390d82ec994a5762536fd2666af57eec Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 28 Jul 2026 13:36:37 +0200 Subject: [PATCH] feat: allow apps to provide themes Adds a generic mechanism for (external) apps to provide selectable themes to the theme switcher, without any app-specific code in the core. - theme store: initializeThemes() merges additional theme configs, so app themes join the switcher alongside the deployment's light/dark themes. - announceTheme(): reads themes from external_apps[].config.themes, loads them parallel to the base theme and injects their stylesheets before the first apply, so a persisted app-theme selection is honored on first paint without a flash. Asset paths are resolved relative to the app's own URL, so a theme's theme.json/stylesheets are found wherever the app is served (dev or prod). - setAndApplyTheme(): expose the active theme as html[data-theme] so themes can attach structural CSS the token system cannot express, and reset custom props a theme does not define instead of leaking them from the previous theme (e.g. a pixel font). Apps declare themes in their manifest's config block, which the server surfaces in external_apps[].config; the Windows 95 theme in the web-extensions repo is the first consumer. --- .../src/composables/piniaStores/theme.ts | 73 +++++++++++++---- .../composables/piniaStores/theme.spec.ts | 14 ++++ .../web-runtime/src/container/bootstrap.ts | 81 ++++++++++++++++--- 3 files changed, 138 insertions(+), 30 deletions(-) diff --git a/packages/web-pkg/src/composables/piniaStores/theme.ts b/packages/web-pkg/src/composables/piniaStores/theme.ts index e386d3eec4..1229df97c0 100644 --- a/packages/web-pkg/src/composables/piniaStores/theme.ts +++ b/packages/web-pkg/src/composables/piniaStores/theme.ts @@ -84,13 +84,33 @@ export const useThemeStore = defineStore('theme', () => { const availableThemes = ref([]) - const initializeThemes = (themeConfig: ThemeConfigType) => { + // Custom prop keys (without the `--oc-` prefix) applied by the active theme, + // so a following theme can clear the ones it doesn't define instead of + // leaking them (applyCustomProp only sets, never clears). + const appliedCustomProps = ref([]) + + const initializeThemes = ( + themeConfig: ThemeConfigType, + additionalThemeConfigs: (ThemeConfigType | undefined)[] = [] + ) => { const commonThemeConfig = themeConfig.common as WebThemeType const webThemeConfig = themeConfig.clients.web.defaults as WebThemeType const baseTheme = merge(commonThemeConfig, webThemeConfig) - availableThemes.value = themeConfig.clients.web.themes.map((theme) => { - return merge(baseTheme, theme) - }) + + const mainThemes = themeConfig.clients.web.themes.map((theme) => merge(baseTheme, theme)) + + // Themes provided by (external) apps. Each may bring its own defaults, + // merged over the base so app themes stay consistent with the deployment. + // Configs that failed to load are undefined and skipped, so a single broken + // app theme cannot take down the base theme. + const additionalThemes = additionalThemeConfigs + .filter((config): config is ThemeConfigType => Boolean(config)) + .flatMap((config) => { + const additionalBaseTheme = merge(baseTheme, config.clients.web.defaults as WebThemeType) + return config.clients.web.themes.map((theme) => merge(additionalBaseTheme, theme)) + }) + + availableThemes.value = [...mainThemes, ...additionalThemes] setThemeFromStorageOrSystem() } @@ -121,30 +141,49 @@ export const useThemeStore = defineStore('theme', () => { } document.documentElement.style.colorScheme = theme.isDark ? 'dark' : 'light' + // Expose the active theme on the DOM so themes can attach structural CSS + // (borders, radii, ...) that the design-token system alone cannot express. + document.documentElement.dataset.theme = theme.label + + // Collect every custom prop this theme defines (font, color roles, the + // deprecated palette). Props the previous theme set but this one omits are + // removed afterwards, so nothing leaks across a theme switch (e.g. a pixel + // font) - applyCustomProp only sets, never clears. + const customProps: Record = {} + const setProp = (key: string, value: string | undefined) => { + if (value !== undefined) { + customProps[key] = value + } + } + + setProp('font-family', theme.designTokens?.fontFamily) + const customizableDesignTokens = [ { name: 'roles', prefix: 'role' }, { name: 'colorPalette', prefix: 'color' } ] as const - - applyCustomProp('font-family', unref(currentTheme).designTokens.fontFamily) - customizableDesignTokens.forEach((token) => { - for (const param in unref(currentTheme).designTokens[token.name]) { - applyCustomProp( - `${token.prefix}-${kebabCase(param)}`, - unref(currentTheme).designTokens[token.name][param] - ) + const tokens = theme.designTokens?.[token.name] + for (const param in tokens) { + setProp(`${token.prefix}-${kebabCase(param)}`, tokens[param]) } }) - if (!unref(currentTheme).designTokens?.roles?.chrome) { + if (!theme.designTokens?.roles?.chrome) { // fallback to surfaceContainer if chrome is not defined since it may not be set - applyCustomProp('role-chrome', unref(currentTheme).designTokens?.roles?.surfaceContainer) - applyCustomProp('role-on-chrome', unref(currentTheme).designTokens?.roles?.onSurface) + setProp('role-chrome', theme.designTokens?.roles?.surfaceContainer) + setProp('role-on-chrome', theme.designTokens?.roles?.onSurface) } - if (unref(currentTheme).favicon) { - setFavicon(unref(currentTheme).favicon) + const root = document.documentElement + unref(appliedCustomProps) + .filter((key) => !(key in customProps)) + .forEach((key) => root.style.removeProperty(`--oc-${key}`)) + Object.entries(customProps).forEach(([key, value]) => applyCustomProp(key, value)) + appliedCustomProps.value = Object.keys(customProps) + + if (theme.favicon) { + setFavicon(theme.favicon) } } diff --git a/packages/web-pkg/tests/unit/composables/piniaStores/theme.spec.ts b/packages/web-pkg/tests/unit/composables/piniaStores/theme.spec.ts index 643f73b8d0..3f12fc6464 100644 --- a/packages/web-pkg/tests/unit/composables/piniaStores/theme.spec.ts +++ b/packages/web-pkg/tests/unit/composables/piniaStores/theme.spec.ts @@ -26,6 +26,20 @@ describe('useThemeStore', () => { expect(store.availableThemes.length).toBe(themeConfig.clients.web.themes.length) }) + it('skips app themes that failed to load without crashing', () => { + const themeConfig = mockDeep() + themeConfig.clients.web.themes = [{ label: 'base', designTokens: {}, isDark: false }] + themeConfig.clients.web.defaults = {} + + const appTheme = mockDeep() + appTheme.clients.web.themes = [{ label: 'app', designTokens: {}, isDark: false }] + appTheme.clients.web.defaults = {} + + const store = useThemeStore() + expect(() => store.initializeThemes(themeConfig, [undefined, appTheme])).not.toThrow() + + expect(store.availableThemes.map((t) => t.label)).toEqual(['base', 'app']) + }) describe('currentTheme', () => { it.each([true, false])('gets set based on the OS setting', (isDark) => { vi.mocked(usePreferredDark).mockReturnValue(computed(() => isDark)) diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 072fd52600..47234c7038 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -92,6 +92,7 @@ import { urlJoin } from '@opencloud-eu/web-client' import { sha256 } from '@noble/hashes/sha2.js' import { bytesToHex } from '@noble/hashes/utils.js' import { injectGeneratorMeta } from '../helpers/meta' +import { z } from 'zod' // Snapshot embed query params on first call so they survive Vue Router // navigations (the delegated-auth callback redirects to /files/spaces/…, @@ -368,6 +369,28 @@ export const announceApplicationsReady = async ({ app.provide(resourceIconMappingInjectionKey, mapping) } +/** + * Shape of a theme provided by an app via external_apps[].config.themes: + * a reference to a theme.json plus optional stylesheets for structural CSS. + */ +const appThemesSchema = z.array( + z.object({ + theme: z.string(), + styles: z.array(z.string()).optional() + }) +) + +const appendStylesheet = (href: string): void => { + if (!href) { + return + } + const link = document.createElement('link') + link.href = href + link.type = 'text/css' + link.rel = 'stylesheet' + document.head.appendChild(link) +} + /** * announce runtime theme to the runtime, this also takes care that the store * and designSystem has all needed information to render the customized ui @@ -388,9 +411,51 @@ export const announceTheme = async ({ const themeStore = useThemeStore() const { initializeThemes } = themeStore - const webTheme = await loadTheme(configStore.theme) + // Themes provided by (external) apps. Apps declare them in their manifest, + // which the server surfaces in external_apps[]. We load them here, parallel to + // the base theme and BEFORE the initial apply, so a persisted selection of an + // app theme is honored on the first paint without a theme flash. + // + // INTERIM: we read them from external_apps[].config.themes, piggybacking on + // the dynamic per-app `config` bag so this works without any backend change. + // The clean shape is a first-class external_apps[].themes field, which needs + // coordinated changes in the opencloud backend (web service config/apps + // structs) and the extension-sdk (manifest metadata). Switch the read below + // to externalApp.themes once that lands. + // Resolve a theme's asset path relative to the app's own URL, so a theme.json + // or stylesheet is found wherever the app is served (dev or prod), no matter + // its deployment location. The app's `path` (its entrypoint) is absolutized + // the same way the app loader does: absolute paths as-is, relative ones + // against serverUrl - so serverUrl is only touched for a relative entrypoint. + const toAppAssetUrl = (appPath: string, asset: string) => { + const entrypoint = /^(https?:)?\/\//.test(appPath) + ? appPath + : urlJoin(configStore.serverUrl, appPath) + return new URL(asset, new URL(entrypoint, window.location.href)).href + } + + const appThemes = (configStore.externalApps ?? []).flatMap((externalApp) => { + const parsed = appThemesSchema.safeParse(externalApp.config?.themes) + if (!parsed.success) { + return [] + } + return parsed.data.map((theme) => ({ + theme: toAppAssetUrl(externalApp.path, theme.theme), + styles: (theme.styles ?? []).map((href) => toAppAssetUrl(externalApp.path, href)) + })) + }) + + const [webTheme, ...additionalThemes] = await Promise.all([ + loadTheme(configStore.theme), + ...appThemes.map((appTheme) => loadTheme(appTheme.theme)) + ]) + + // Inject each app theme's stylesheets early (they are scoped by [data-theme] + // and inert until their theme is active), so the structural CSS is present + // before a persisted app theme gets applied. + appThemes.flatMap((appTheme) => appTheme.styles ?? []).forEach((href) => appendStylesheet(href)) - initializeThemes(webTheme) + initializeThemes(webTheme, additionalThemes) app.use(designSystem) } @@ -807,17 +872,7 @@ export const announceCustomScripts = ({ configStore }: { configStore?: ConfigSto * @param configStore */ export const announceCustomStyles = ({ configStore }: { configStore?: ConfigStore }): void => { - configStore.styles.forEach(({ href = '' }) => { - if (!href) { - return - } - - const link = document.createElement('link') - link.href = href - link.type = 'text/css' - link.rel = 'stylesheet' - document.head.appendChild(link) - }) + configStore.styles.forEach(({ href = '' }) => appendStylesheet(href)) } /**