// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * Theme Provider Component * * Applies theme tokens and sets color-scheme for light-dark() to work. * Themes are created with `defineTheme()` and applied via CSS: * - Token overrides set as CSS custom properties on [data-astryx-theme] * - Component overrides scoped via @scope'd CSS selectors on stable Astryx * target classes combined with reflected `data-*` prop/state attributes * * Root detection: The first Theme in the tree (no parent Theme) * automatically syncs attributes to `document.documentElement`: * - `data-theme` — drives `color-scheme` via reset.css rules, ensuring browser * chrome (scrollbars, native form controls, date pickers) reflects the mode. * - `data-astryx-theme` — enables @scope'd theme CSS to reach elements rendered * outside the Theme wrapper (portals, toast fallback viewports). * * For RSC / SSR, set `data-theme` on `` in your root server layout * to avoid a flash of wrong theme before hydration: * * * * @example * ``` * const ocean = defineTheme({ * name: 'ocean', * tokens: { '--color-accent': ['#0077B6', '#48CAE4'] }, * components: { card: { base: { borderWidth: '2px' } } }, * icons: oceanIcons, * }); * * ``` */ import React, { use, useId, useInsertionEffect, useMemo } from 'react'; import { useIsomorphicLayoutEffect } from "../hooks/useIsomorphicLayoutEffect.js"; import * as stylex from "../style-runtime.js"; import type { ThemeMode } from "./types.js"; import "./tokens.stylex.js"; import { colorVars, typographyVars } from "./tokens.stylex.js"; import { generateThemeCSS, type DefinedTheme } from "./defineTheme.js"; import { generateDataTokenDefaultsCSS } from "./generateThemeRules.js"; import { registerTheme } from "./themeRegistry.js"; import { dataAttr } from "../naming.js"; import { ThemeContext } from "./useTheme.js"; import { warnOnce } from "../utils/devWarning.js"; /** * Theme provider props */ interface ThemeProps { /** Theme from defineTheme() */ theme: DefinedTheme; /** Color mode - 'system' follows OS preference */ mode?: ThemeMode; /** Children to render */ children: React.ReactNode; } /** * Styles for the theme wrapper */ const wrapperStyles = { base: { k1xSpc: "xjp7ctv", kMwMTN: "x1tgivj0", kMv6JI: "x9ynric", $$css: true as const }, light: { kQNsl9: "x19aimcq", $$css: true as const }, dark: { kQNsl9: "xntwwlm", $$css: true as const }, system: { kQNsl9: "x108lcm5", $$css: true as const } }; // ============================================================================= // Nesting context — detect root vs nested Theme // ============================================================================= /** * Context to detect whether this Theme is nested inside another. * The root provider (no parent context) syncs data-theme to . * @internal */ const ThemeNestingContext = React.createContext(false); ThemeNestingContext.displayName = 'ThemeNestingContext'; // ============================================================================= // Style injection for unbuilt themes // ============================================================================= /** Track which themes have already been injected */ const injectedThemes = new Set(); /** * How many mounted `Theme`s are relying on the injected data-token defaults. * * The defaults are one document-wide `:root` block, shared by every theme, so * they are injected once and removed only when the last theme that took a * reference unmounts — tearing them down with whichever provider happened to * inject them would strip the palette from the providers still mounted. */ let dataTokenDefaultsRefCount = 0; /** * Hook to inject theme CSS into the document. * Built themes (from `astryx theme build`) skip injection — their CSS * is in a separate file imported by the consumer. */ function useThemeStyleInjection(theme: DefinedTheme): void { const id = useId(); useInsertionEffect(() => { // Built themes have their CSS in a separate file — skip injection if (theme.__built) { return; } // Second and later `Theme`s using this theme do no work at all — // generation included. Its CSS is document-wide and identical for each. const themeKey = `astryx-theme-${theme.name}`; if (injectedThemes.has(themeKey)) { return; } // One-time perf hint per theme warnOnce(`theme-injection:${theme.name}`, 'Theme', `"${theme.name}" is using runtime style injection. ` + `For better performance, use the pre-built theme:\n\n` + ` import {${theme.name}Theme} from '@astryxdesign/theme-${theme.name}/built';\n` + ` import '@astryxdesign/theme-${theme.name}/theme.css';\n\n` + `For custom themes, run \`npx @astryxdesign/cli theme build \` to generate ` + `the built artifacts.`); const { prose, component } = generateThemeCSS(theme); const base = generateDataTokenDefaultsCSS(); injectedThemes.add(themeKey); const cleanups: (() => void)[] = [() => injectedThemes.delete(themeKey)]; // Data token defaults go into @layer astryx-base, where StyleX puts the // core token defaults, so a theme's own `--color-data-*` outranks them by // layer. Appended (never prepended) so it cannot register `astryx-base` // ahead of `reset` and invert the layer order. if (base) { if (dataTokenDefaultsRefCount++ === 0) { const baseStyle = document.createElement('style'); baseStyle.setAttribute(dataAttr('theme-base'), ''); baseStyle.textContent = `@layer astryx-base {\n${base}\n}`; document.head.appendChild(baseStyle); } cleanups.push(() => { if (--dataTokenDefaultsRefCount === 0) { document.querySelector(`style[${dataAttr('theme-base')}]`)?.remove(); } }); } // Prose defaults go into @layer reset — lowest priority, scoped to // the theme region. Any class-based style (StyleX, .xds-*) wins. if (prose) { const proseStyle = document.createElement('style'); proseStyle.setAttribute(dataAttr('theme-prose'), theme.name); proseStyle.setAttribute(dataAttr('id'), id); proseStyle.textContent = `@layer reset {\n${prose}\n}`; document.head.appendChild(proseStyle); } // Component overrides go into @layer astryx-theme — above StyleX layers // so themes can intentionally restyle components. if (component) { const compStyle = document.createElement('style'); compStyle.setAttribute(dataAttr('theme'), theme.name); compStyle.setAttribute(dataAttr('id'), id); compStyle.textContent = `@layer astryx-theme {\n${component}\n}`; document.head.appendChild(compStyle); } if (prose || component) { cleanups.push(() => { // Matched by the astryx id marker, which is always written above. const proseEl = document.querySelector(`style[${dataAttr('theme-prose')}="${theme.name}"][${dataAttr('id')}="${id}"]`); const compEl = document.querySelector(`style[${dataAttr('theme')}="${theme.name}"][${dataAttr('id')}="${id}"]`); proseEl?.remove(); compEl?.remove(); }); } return () => { for (const cleanup of cleanups) { cleanup(); } }; }, [theme, id]); } // ============================================================================= // Root color-scheme sync // ============================================================================= /** * Hook to sync theme attributes to document.documentElement for the root provider. * Skipped for nested Theme instances. * * Syncs two attributes: * - `data-theme` (light/dark) — reset.css maps this to color-scheme, controlling * browser chrome (scrollbars, native form controls, date pickers). * - `data-astryx-theme` (theme name) — enables @scope'd theme CSS to reach elements * outside the Theme wrapper (e.g. toast fallback viewports, portals). * * - 'light' | 'dark' → sets data-theme="light" | "dark" * - 'system' → removes data-theme (reset.css defaults to color-scheme: light dark) */ function useRootThemeSync(isNested: boolean, mode: ThemeMode, themeName: string): void { useIsomorphicLayoutEffect(() => { if (isNested) { return; } if (typeof document === 'undefined') { return; } if (mode === 'light' || mode === 'dark') { document.documentElement.setAttribute('data-theme', mode); } else { // system — remove attribute, let reset.css default apply document.documentElement.removeAttribute('data-theme'); } // Sync theme name so @scope rules reach portals/fallback viewports. document.documentElement.setAttribute(dataAttr('theme'), themeName); return () => { document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute(dataAttr('theme')); }; }, [isNested, mode, themeName]); } // ============================================================================= // Component // ============================================================================= /** * Theme provider component * * Sets data-astryx-theme attribute so @scope'd CSS takes effect. * Component overrides are pure CSS scoped under the theme attribute — * components render with stable `.xds-*` classes plus `data-*` prop * reflections and don't need context. * * When this is the root Theme (no parent Theme in the tree), * it syncs `data-theme` and `data-astryx-theme` to `` so browser * chrome reflects the active mode and @scope'd CSS reaches portals. * Nested Theme instances skip the sync. */ export function Theme({ theme, mode = 'system', children }: ThemeProps): React.ReactElement { const isNested = use(ThemeNestingContext); registerTheme(theme); useThemeStyleInjection(theme); useRootThemeSync(isNested, mode, theme.name); // Get color-scheme style const colorSchemeStyle = mode === 'dark' ? wrapperStyles.dark : mode === 'light' ? wrapperStyles.light : wrapperStyles.system; // Memoize the context value to prevent unnecessary re-renders const ctxValue = useMemo(() => ({ theme, mode }), [theme, mode]); return
{children}
; } Theme.displayName = 'Theme';