diff --git a/.changeset/theme-runtime-improvements.md b/.changeset/theme-runtime-improvements.md new file mode 100644 index 000000000..15615ac41 --- /dev/null +++ b/.changeset/theme-runtime-improvements.md @@ -0,0 +1,14 @@ +--- +"@tiny-design/react": minor +"@tiny-design/tokens": minor +--- + +Theme runtime and token registry improvements: + +- **Fix:** `base.css` now emits light defaults under `:root, [data-tiny-theme='light']`, so a scoped `` inside a dark root flips correctly. +- **New:** `getThemeStylesheet(theme, { selector? })` exported from `@tiny-design/tokens/resolve-theme` (and re-exported from `@tiny-design/react`) returns a CSS string for SSR injection to avoid theme FOUC. +- **New:** `useActiveTheme()` exported from `@tiny-design/react` returns the effective `{ mode, themeConfig }` for the current subtree. +- **New:** `useTheme()` is now context-aware (respects the nearest `ConfigProvider`'s theme) and accepts `{ initialMode }` for SSR hydration. +- **New:** Typed token key unions in `dist/registry.d.ts` (`PrimitiveTokenKey`, `SemanticTokenKey`, `ComponentTokenKey`, `TokenKey`) and a `TypedThemeDocument` in `dist/presets.d.ts` for autocompletion. +- **New:** Additive primitive token layer under `source/primitive/` with initial brand color scale and spacing scale. +- **Build:** Token registry validation now checks `fallback` targets and `$type` vs `$value` compatibility. diff --git a/packages/react/src/_utils/theme-store.ts b/packages/react/src/_utils/theme-store.ts new file mode 100644 index 000000000..f2571c06a --- /dev/null +++ b/packages/react/src/_utils/theme-store.ts @@ -0,0 +1,136 @@ +import type { ThemeMode } from '../config-provider/config-context'; + +const STORAGE_KEY = 'ty-theme'; +const THEME_ATTR = 'data-tiny-theme'; + +function readDomTheme(): ThemeMode | null { + if (typeof document === 'undefined') return null; + const value = document.documentElement.getAttribute(THEME_ATTR); + return value === 'light' || value === 'dark' || value === 'system' ? value : null; +} + +function readStoredTheme(): ThemeMode | null { + if (typeof localStorage === 'undefined') return null; + const value = localStorage.getItem(STORAGE_KEY); + return value === 'light' || value === 'dark' || value === 'system' ? value : null; +} + +function readInitialTheme(): ThemeMode { + return readDomTheme() ?? readStoredTheme() ?? 'light'; +} + +function applyTheme(mode: ThemeMode): void { + if (typeof document === 'undefined') return; + document.documentElement.setAttribute(THEME_ATTR, mode); +} + +export function getSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +let currentMode: ThemeMode = readInitialTheme(); +const listeners = new Set<() => void>(); +let initialized = false; + +function emit(): void { + listeners.forEach((cb) => cb()); +} + +function ensureInitialized(): void { + if (initialized) return; + initialized = true; + + // Respect any mode that's already on the DOM (e.g. written by an inline + // pre-hydration script) before we start broadcasting. + const dom = readDomTheme(); + if (dom) { + currentMode = dom; + } else if (typeof document !== 'undefined') { + applyTheme(currentMode); + } + + if (typeof window === 'undefined') return; + + if (typeof window.matchMedia === 'function') { + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => { + if (currentMode === 'system') emit(); + }; + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handler); + } else if (typeof (mql as MediaQueryList).addListener === 'function') { + (mql as MediaQueryList).addListener(handler); + } + } + + window.addEventListener('storage', (event) => { + if (event.key !== STORAGE_KEY) return; + const next = + event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system' + ? event.newValue + : 'light'; + currentMode = next; + applyTheme(currentMode); + emit(); + }); +} + +export const themeStore = { + subscribe(cb: () => void): () => void { + ensureInitialized(); + const syncFromDom = () => { + const dom = readDomTheme(); + if (dom && dom !== currentMode) { + currentMode = dom; + cb(); + } + }; + listeners.add(cb); + syncFromDom(); + + const observer = + typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' + ? new MutationObserver(syncFromDom) + : null; + observer?.observe(document.documentElement, { + attributes: true, + attributeFilter: [THEME_ATTR], + }); + + return () => { + listeners.delete(cb); + observer?.disconnect(); + }; + }, + getSnapshot(): ThemeMode { + return readDomTheme() ?? currentMode; + }, + getServerSnapshot(): ThemeMode { + return 'light'; + }, + setMode(next: ThemeMode): void { + ensureInitialized(); + currentMode = next; + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + /* ignore quota/privacy errors */ + } + } + applyTheme(next); + emit(); + }, + /** + * Initialize the store with an explicit mode before any render. Useful for + * SSR hydration when the initial mode comes from a cookie or inline script. + */ + hydrate(next: ThemeMode): void { + currentMode = next; + if (typeof document !== 'undefined') { + applyTheme(next); + } + initialized = true; + }, +}; diff --git a/packages/react/src/_utils/use-theme.ts b/packages/react/src/_utils/use-theme.ts index 56bde0b44..a365409e5 100644 --- a/packages/react/src/_utils/use-theme.ts +++ b/packages/react/src/_utils/use-theme.ts @@ -1,137 +1,39 @@ -import { useSyncExternalStore, useCallback } from 'react'; +import { useSyncExternalStore, useCallback, useContext } from 'react'; import type { ThemeMode } from '../config-provider/config-context'; +import { ConfigContext } from '../config-provider/config-context'; +import { getSystemTheme, themeStore } from './theme-store'; -const STORAGE_KEY = 'ty-theme'; -const THEME_ATTR = 'data-tiny-theme'; - -function getSystemTheme(): 'light' | 'dark' { - if (typeof window === 'undefined') return 'light'; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; -} - -function applyTheme(mode: ThemeMode): void { - if (typeof document === 'undefined') return; - document.documentElement.setAttribute(THEME_ATTR, mode); -} - -function readDomTheme(): ThemeMode | null { - if (typeof document === 'undefined') return null; - const value = document.documentElement.getAttribute(THEME_ATTR); - return value === 'light' || value === 'dark' || value === 'system' ? value : null; -} - -function readStoredTheme(): ThemeMode { - if (typeof localStorage === 'undefined') return 'light'; - return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'light'; -} - -function readInitialTheme(): ThemeMode { - return readDomTheme() ?? readStoredTheme(); -} - -// ---- Shared store ---- -let currentMode: ThemeMode = readInitialTheme(); -const listeners = new Set<() => void>(); - -function getSnapshot(): ThemeMode { - return readDomTheme() ?? currentMode; -} - -function getServerSnapshot(): ThemeMode { - return 'light'; -} - -function subscribe(cb: () => void): () => void { - const syncFromDom = () => { - const domTheme = readDomTheme(); - - if (domTheme && domTheme !== currentMode) { - currentMode = domTheme; - cb(); - } - }; - - listeners.add(cb); - syncFromDom(); - - const observer = - typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' - ? new MutationObserver(() => { - syncFromDom(); - }) - : null; - - observer?.observe(document.documentElement, { - attributes: true, - attributeFilter: [THEME_ATTR], - }); - - return () => { - listeners.delete(cb); - observer?.disconnect(); - }; -} - -function setThemeMode(next: ThemeMode): void { - currentMode = next; - if (typeof localStorage !== 'undefined') { - localStorage.setItem(STORAGE_KEY, next); - } - applyTheme(next); - listeners.forEach((cb) => cb()); +export interface UseThemeOptions { + /** + * Initial mode to hydrate the store with. Use on first mount for SSR to + * align with the mode written to the document by a pre-hydration script. + */ + initialMode?: ThemeMode; } -function emit(): void { - listeners.forEach((cb) => cb()); -} - -// Listen for system preference changes at module level -if (typeof document !== 'undefined') { - applyTheme(currentMode); -} - -if (typeof window !== 'undefined') { - if (typeof window.matchMedia === 'function') { - const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); - const handleSystemThemeChange = () => { - if (currentMode === 'system') { - emit(); - } - }; - - if (typeof mediaQueryList.addEventListener === 'function') { - mediaQueryList.addEventListener('change', handleSystemThemeChange); - } else if (typeof mediaQueryList.addListener === 'function') { - mediaQueryList.addListener(handleSystemThemeChange); - } +export function useTheme(options?: UseThemeOptions) { + if (options?.initialMode) { + themeStore.hydrate(options.initialMode); } - window.addEventListener('storage', (event) => { - if (event.key !== STORAGE_KEY) { - return; - } - - currentMode = event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system' - ? event.newValue - : 'light'; - applyTheme(currentMode); - emit(); - }); -} - -// ---- Hook ---- -export function useTheme() { - const mode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const contextMode = useContext(ConfigContext).theme; + const storeMode = useSyncExternalStore( + themeStore.subscribe, + themeStore.getSnapshot, + themeStore.getServerSnapshot + ); + const mode: ThemeMode = contextMode ?? storeMode; const resolvedTheme: 'light' | 'dark' = mode === 'system' ? getSystemTheme() : mode; - const setMode = useCallback((newMode: ThemeMode) => { - setThemeMode(newMode); + const setMode = useCallback((next: ThemeMode) => { + themeStore.setMode(next); }, []); const toggle = useCallback(() => { - const resolved = currentMode === 'system' ? getSystemTheme() : currentMode; - setThemeMode(resolved === 'light' ? 'dark' : 'light'); + const active = themeStore.getSnapshot(); + const resolved = active === 'system' ? getSystemTheme() : active; + themeStore.setMode(resolved === 'light' ? 'dark' : 'light'); }, []); return { mode, resolvedTheme, setMode, toggle }; diff --git a/packages/react/src/config-provider/config-context.tsx b/packages/react/src/config-provider/config-context.tsx index bf9db6ae6..48d688033 100644 --- a/packages/react/src/config-provider/config-context.tsx +++ b/packages/react/src/config-provider/config-context.tsx @@ -3,6 +3,7 @@ import { SizeType } from '../_utils/props'; import { SpaceSize } from '../space/types'; import { Locale } from '../locale/types'; import { SkeletonAnimation } from '../skeleton/types'; +import { themeStore } from '../_utils/theme-store'; import { ThemeConfig } from './token-utils'; export type ThemeMode = 'light' | 'dark' | 'system'; @@ -35,3 +36,30 @@ export const ConfigContext = React.createContext({ export function useConfig(): ConfigContextProps { return React.useContext(ConfigContext); } + +/** + * Returns the theme that is actually taking effect in this part of the tree. + * + * Resolution order: + * 1. The nearest ConfigProvider's `theme` prop (scoped override) + * 2. The document-level theme store driven by `useTheme().setMode(...)` + * + * `themeConfig` is the full ThemeConfig object when the nearest provider was + * configured with one; otherwise undefined. + */ +export function useActiveTheme(): { + mode: ThemeMode | undefined; + themeConfig: ThemeConfig | undefined; +} { + const ctx = React.useContext(ConfigContext); + const storeSnapshot = React.useSyncExternalStore( + themeStore.subscribe, + themeStore.getSnapshot, + themeStore.getServerSnapshot + ); + + return { + mode: ctx.theme ?? storeSnapshot, + themeConfig: ctx.themeConfig, + }; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index aaaa8525a..55860a33c 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -180,4 +180,6 @@ export type * from './waterfall'; export { useLocale } from './_utils/use-locale'; export { useTheme } from './_utils/use-theme'; +export { useActiveTheme } from './config-provider/config-context'; +export { getThemeStylesheet } from '@tiny-design/tokens/resolve-theme'; export type { ThemeMode } from './config-provider/config-context'; diff --git a/packages/tokens/runtime/resolve-theme.cjs b/packages/tokens/runtime/resolve-theme.cjs index 6c11f52a9..54816341b 100644 --- a/packages/tokens/runtime/resolve-theme.cjs +++ b/packages/tokens/runtime/resolve-theme.cjs @@ -5,4 +5,5 @@ const runtime = require('./theme-runtime.cjs'); module.exports = { resolveTheme: runtime.resolveTheme, tokenKeyToCssVar: runtime.tokenKeyToCssVar, + getThemeStylesheet: runtime.getThemeStylesheet, }; diff --git a/packages/tokens/runtime/resolve-theme.d.ts b/packages/tokens/runtime/resolve-theme.d.ts index fe9c985be..71244a74d 100644 --- a/packages/tokens/runtime/resolve-theme.d.ts +++ b/packages/tokens/runtime/resolve-theme.d.ts @@ -40,19 +40,35 @@ export interface ResolvedThemeResult { normalizedDocument: ThemeConfig; } +export interface ResolveThemeOptions { + strict?: boolean; + presets?: Record; + registry?: { + tokens: Array<{ + key: string; + category: 'semantic' | 'component' | 'primitive'; + status: 'active' | 'deprecated' | 'internal'; + defaultValue: string | number; + }>; + }; +} + +export interface GetThemeStylesheetOptions extends ResolveThemeOptions { + /** CSS selector prefix for the rule. Defaults to `:root`. */ + selector?: string; +} + export function tokenKeyToCssVar(key: string): string; export function resolveTheme( input: ThemeConfig | ThemeDocument, - options?: { - strict?: boolean; - presets?: Record; - registry?: { - tokens: Array<{ - key: string; - category: 'semantic' | 'component' | 'primitive'; - status: 'active' | 'deprecated' | 'internal'; - defaultValue: string | number; - }>; - }; - } + options?: ResolveThemeOptions ): ResolvedThemeResult; +/** + * Returns a CSS string that applies the theme's overrides under the given + * selector. Safe to inline in a server-rendered `