Skip to content
Draft
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
73 changes: 56 additions & 17 deletions packages/web-pkg/src/composables/piniaStores/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,33 @@ export const useThemeStore = defineStore('theme', () => {

const availableThemes = ref<WebThemeType[]>([])

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<string[]>([])

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

Expand Down Expand Up @@ -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<string, string> = {}
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)
}
}

Expand Down
14 changes: 14 additions & 0 deletions packages/web-pkg/tests/unit/composables/piniaStores/theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThemeConfigType>()
themeConfig.clients.web.themes = [{ label: 'base', designTokens: {}, isDark: false }]
themeConfig.clients.web.defaults = {}

const appTheme = mockDeep<ThemeConfigType>()
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))
Expand Down
81 changes: 68 additions & 13 deletions packages/web-runtime/src/container/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/…,
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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))
}

/**
Expand Down