Skip to content
Closed
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
14 changes: 14 additions & 0 deletions .changeset/theme-runtime-improvements.md
Original file line number Diff line number Diff line change
@@ -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 `<ConfigProvider theme="light">` 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.
136 changes: 136 additions & 0 deletions packages/react/src/_utils/theme-store.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
146 changes: 24 additions & 122 deletions packages/react/src/_utils/use-theme.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down
28 changes: 28 additions & 0 deletions packages/react/src/config-provider/config-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,3 +36,30 @@ export const ConfigContext = React.createContext<ConfigContextProps>({
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,
};
}
2 changes: 2 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions packages/tokens/runtime/resolve-theme.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ const runtime = require('./theme-runtime.cjs');
module.exports = {
resolveTheme: runtime.resolveTheme,
tokenKeyToCssVar: runtime.tokenKeyToCssVar,
getThemeStylesheet: runtime.getThemeStylesheet,
};
Loading
Loading