Skip to content

Commit fa2717e

Browse files
wangdicoderclaude
andcommitted
feat(tokens,react): theme runtime improvements and token registry upgrades
Fix scoped light-inside-dark theming, add SSR stylesheet helper, typed token keys, a primitive token layer, and unify useTheme with ConfigProvider via a shared external store. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06330e0 commit fa2717e

13 files changed

Lines changed: 543 additions & 141 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@tiny-design/react": minor
3+
"@tiny-design/tokens": minor
4+
---
5+
6+
Theme runtime and token registry improvements:
7+
8+
- **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.
9+
- **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.
10+
- **New:** `useActiveTheme()` exported from `@tiny-design/react` returns the effective `{ mode, themeConfig }` for the current subtree.
11+
- **New:** `useTheme()` is now context-aware (respects the nearest `ConfigProvider`'s theme) and accepts `{ initialMode }` for SSR hydration.
12+
- **New:** Typed token key unions in `dist/registry.d.ts` (`PrimitiveTokenKey`, `SemanticTokenKey`, `ComponentTokenKey`, `TokenKey`) and a `TypedThemeDocument` in `dist/presets.d.ts` for autocompletion.
13+
- **New:** Additive primitive token layer under `source/primitive/` with initial brand color scale and spacing scale.
14+
- **Build:** Token registry validation now checks `fallback` targets and `$type` vs `$value` compatibility.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { ThemeMode } from '../config-provider/config-context';
2+
3+
const STORAGE_KEY = 'ty-theme';
4+
const THEME_ATTR = 'data-tiny-theme';
5+
6+
function readDomTheme(): ThemeMode | null {
7+
if (typeof document === 'undefined') return null;
8+
const value = document.documentElement.getAttribute(THEME_ATTR);
9+
return value === 'light' || value === 'dark' || value === 'system' ? value : null;
10+
}
11+
12+
function readStoredTheme(): ThemeMode | null {
13+
if (typeof localStorage === 'undefined') return null;
14+
const value = localStorage.getItem(STORAGE_KEY);
15+
return value === 'light' || value === 'dark' || value === 'system' ? value : null;
16+
}
17+
18+
function readInitialTheme(): ThemeMode {
19+
return readDomTheme() ?? readStoredTheme() ?? 'light';
20+
}
21+
22+
function applyTheme(mode: ThemeMode): void {
23+
if (typeof document === 'undefined') return;
24+
document.documentElement.setAttribute(THEME_ATTR, mode);
25+
}
26+
27+
export function getSystemTheme(): 'light' | 'dark' {
28+
if (typeof window === 'undefined') return 'light';
29+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
30+
}
31+
32+
let currentMode: ThemeMode = readInitialTheme();
33+
const listeners = new Set<() => void>();
34+
let initialized = false;
35+
36+
function emit(): void {
37+
listeners.forEach((cb) => cb());
38+
}
39+
40+
function ensureInitialized(): void {
41+
if (initialized) return;
42+
initialized = true;
43+
44+
// Respect any mode that's already on the DOM (e.g. written by an inline
45+
// pre-hydration script) before we start broadcasting.
46+
const dom = readDomTheme();
47+
if (dom) {
48+
currentMode = dom;
49+
} else if (typeof document !== 'undefined') {
50+
applyTheme(currentMode);
51+
}
52+
53+
if (typeof window === 'undefined') return;
54+
55+
if (typeof window.matchMedia === 'function') {
56+
const mql = window.matchMedia('(prefers-color-scheme: dark)');
57+
const handler = () => {
58+
if (currentMode === 'system') emit();
59+
};
60+
if (typeof mql.addEventListener === 'function') {
61+
mql.addEventListener('change', handler);
62+
} else if (typeof (mql as MediaQueryList).addListener === 'function') {
63+
(mql as MediaQueryList).addListener(handler);
64+
}
65+
}
66+
67+
window.addEventListener('storage', (event) => {
68+
if (event.key !== STORAGE_KEY) return;
69+
const next =
70+
event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system'
71+
? event.newValue
72+
: 'light';
73+
currentMode = next;
74+
applyTheme(currentMode);
75+
emit();
76+
});
77+
}
78+
79+
export const themeStore = {
80+
subscribe(cb: () => void): () => void {
81+
ensureInitialized();
82+
const syncFromDom = () => {
83+
const dom = readDomTheme();
84+
if (dom && dom !== currentMode) {
85+
currentMode = dom;
86+
cb();
87+
}
88+
};
89+
listeners.add(cb);
90+
syncFromDom();
91+
92+
const observer =
93+
typeof MutationObserver !== 'undefined' && typeof document !== 'undefined'
94+
? new MutationObserver(syncFromDom)
95+
: null;
96+
observer?.observe(document.documentElement, {
97+
attributes: true,
98+
attributeFilter: [THEME_ATTR],
99+
});
100+
101+
return () => {
102+
listeners.delete(cb);
103+
observer?.disconnect();
104+
};
105+
},
106+
getSnapshot(): ThemeMode {
107+
return readDomTheme() ?? currentMode;
108+
},
109+
getServerSnapshot(): ThemeMode {
110+
return 'light';
111+
},
112+
setMode(next: ThemeMode): void {
113+
ensureInitialized();
114+
currentMode = next;
115+
if (typeof localStorage !== 'undefined') {
116+
try {
117+
localStorage.setItem(STORAGE_KEY, next);
118+
} catch {
119+
/* ignore quota/privacy errors */
120+
}
121+
}
122+
applyTheme(next);
123+
emit();
124+
},
125+
/**
126+
* Initialize the store with an explicit mode before any render. Useful for
127+
* SSR hydration when the initial mode comes from a cookie or inline script.
128+
*/
129+
hydrate(next: ThemeMode): void {
130+
currentMode = next;
131+
if (typeof document !== 'undefined') {
132+
applyTheme(next);
133+
}
134+
initialized = true;
135+
},
136+
};

packages/react/src/_utils/use-theme.ts

Lines changed: 24 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,137 +1,39 @@
1-
import { useSyncExternalStore, useCallback } from 'react';
1+
import { useSyncExternalStore, useCallback, useContext } from 'react';
22
import type { ThemeMode } from '../config-provider/config-context';
3+
import { ConfigContext } from '../config-provider/config-context';
4+
import { getSystemTheme, themeStore } from './theme-store';
35

4-
const STORAGE_KEY = 'ty-theme';
5-
const THEME_ATTR = 'data-tiny-theme';
6-
7-
function getSystemTheme(): 'light' | 'dark' {
8-
if (typeof window === 'undefined') return 'light';
9-
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
10-
}
11-
12-
function applyTheme(mode: ThemeMode): void {
13-
if (typeof document === 'undefined') return;
14-
document.documentElement.setAttribute(THEME_ATTR, mode);
15-
}
16-
17-
function readDomTheme(): ThemeMode | null {
18-
if (typeof document === 'undefined') return null;
19-
const value = document.documentElement.getAttribute(THEME_ATTR);
20-
return value === 'light' || value === 'dark' || value === 'system' ? value : null;
21-
}
22-
23-
function readStoredTheme(): ThemeMode {
24-
if (typeof localStorage === 'undefined') return 'light';
25-
return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'light';
26-
}
27-
28-
function readInitialTheme(): ThemeMode {
29-
return readDomTheme() ?? readStoredTheme();
30-
}
31-
32-
// ---- Shared store ----
33-
let currentMode: ThemeMode = readInitialTheme();
34-
const listeners = new Set<() => void>();
35-
36-
function getSnapshot(): ThemeMode {
37-
return readDomTheme() ?? currentMode;
38-
}
39-
40-
function getServerSnapshot(): ThemeMode {
41-
return 'light';
42-
}
43-
44-
function subscribe(cb: () => void): () => void {
45-
const syncFromDom = () => {
46-
const domTheme = readDomTheme();
47-
48-
if (domTheme && domTheme !== currentMode) {
49-
currentMode = domTheme;
50-
cb();
51-
}
52-
};
53-
54-
listeners.add(cb);
55-
syncFromDom();
56-
57-
const observer =
58-
typeof MutationObserver !== 'undefined' && typeof document !== 'undefined'
59-
? new MutationObserver(() => {
60-
syncFromDom();
61-
})
62-
: null;
63-
64-
observer?.observe(document.documentElement, {
65-
attributes: true,
66-
attributeFilter: [THEME_ATTR],
67-
});
68-
69-
return () => {
70-
listeners.delete(cb);
71-
observer?.disconnect();
72-
};
73-
}
74-
75-
function setThemeMode(next: ThemeMode): void {
76-
currentMode = next;
77-
if (typeof localStorage !== 'undefined') {
78-
localStorage.setItem(STORAGE_KEY, next);
79-
}
80-
applyTheme(next);
81-
listeners.forEach((cb) => cb());
6+
export interface UseThemeOptions {
7+
/**
8+
* Initial mode to hydrate the store with. Use on first mount for SSR to
9+
* align with the mode written to the document by a pre-hydration script.
10+
*/
11+
initialMode?: ThemeMode;
8212
}
8313

84-
function emit(): void {
85-
listeners.forEach((cb) => cb());
86-
}
87-
88-
// Listen for system preference changes at module level
89-
if (typeof document !== 'undefined') {
90-
applyTheme(currentMode);
91-
}
92-
93-
if (typeof window !== 'undefined') {
94-
if (typeof window.matchMedia === 'function') {
95-
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
96-
const handleSystemThemeChange = () => {
97-
if (currentMode === 'system') {
98-
emit();
99-
}
100-
};
101-
102-
if (typeof mediaQueryList.addEventListener === 'function') {
103-
mediaQueryList.addEventListener('change', handleSystemThemeChange);
104-
} else if (typeof mediaQueryList.addListener === 'function') {
105-
mediaQueryList.addListener(handleSystemThemeChange);
106-
}
14+
export function useTheme(options?: UseThemeOptions) {
15+
if (options?.initialMode) {
16+
themeStore.hydrate(options.initialMode);
10717
}
10818

109-
window.addEventListener('storage', (event) => {
110-
if (event.key !== STORAGE_KEY) {
111-
return;
112-
}
113-
114-
currentMode = event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system'
115-
? event.newValue
116-
: 'light';
117-
applyTheme(currentMode);
118-
emit();
119-
});
120-
}
121-
122-
// ---- Hook ----
123-
export function useTheme() {
124-
const mode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
19+
const contextMode = useContext(ConfigContext).theme;
20+
const storeMode = useSyncExternalStore(
21+
themeStore.subscribe,
22+
themeStore.getSnapshot,
23+
themeStore.getServerSnapshot
24+
);
25+
const mode: ThemeMode = contextMode ?? storeMode;
12526

12627
const resolvedTheme: 'light' | 'dark' = mode === 'system' ? getSystemTheme() : mode;
12728

128-
const setMode = useCallback((newMode: ThemeMode) => {
129-
setThemeMode(newMode);
29+
const setMode = useCallback((next: ThemeMode) => {
30+
themeStore.setMode(next);
13031
}, []);
13132

13233
const toggle = useCallback(() => {
133-
const resolved = currentMode === 'system' ? getSystemTheme() : currentMode;
134-
setThemeMode(resolved === 'light' ? 'dark' : 'light');
34+
const active = themeStore.getSnapshot();
35+
const resolved = active === 'system' ? getSystemTheme() : active;
36+
themeStore.setMode(resolved === 'light' ? 'dark' : 'light');
13537
}, []);
13638

13739
return { mode, resolvedTheme, setMode, toggle };

packages/react/src/config-provider/config-context.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SizeType } from '../_utils/props';
33
import { SpaceSize } from '../space/types';
44
import { Locale } from '../locale/types';
55
import { SkeletonAnimation } from '../skeleton/types';
6+
import { themeStore } from '../_utils/theme-store';
67
import { ThemeConfig } from './token-utils';
78

89
export type ThemeMode = 'light' | 'dark' | 'system';
@@ -35,3 +36,30 @@ export const ConfigContext = React.createContext<ConfigContextProps>({
3536
export function useConfig(): ConfigContextProps {
3637
return React.useContext(ConfigContext);
3738
}
39+
40+
/**
41+
* Returns the theme that is actually taking effect in this part of the tree.
42+
*
43+
* Resolution order:
44+
* 1. The nearest ConfigProvider's `theme` prop (scoped override)
45+
* 2. The document-level theme store driven by `useTheme().setMode(...)`
46+
*
47+
* `themeConfig` is the full ThemeConfig object when the nearest provider was
48+
* configured with one; otherwise undefined.
49+
*/
50+
export function useActiveTheme(): {
51+
mode: ThemeMode | undefined;
52+
themeConfig: ThemeConfig | undefined;
53+
} {
54+
const ctx = React.useContext(ConfigContext);
55+
const storeSnapshot = React.useSyncExternalStore(
56+
themeStore.subscribe,
57+
themeStore.getSnapshot,
58+
themeStore.getServerSnapshot
59+
);
60+
61+
return {
62+
mode: ctx.theme ?? storeSnapshot,
63+
themeConfig: ctx.themeConfig,
64+
};
65+
}

packages/react/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,6 @@ export type * from './waterfall';
180180

181181
export { useLocale } from './_utils/use-locale';
182182
export { useTheme } from './_utils/use-theme';
183+
export { useActiveTheme } from './config-provider/config-context';
184+
export { getThemeStylesheet } from '@tiny-design/tokens/resolve-theme';
183185
export type { ThemeMode } from './config-provider/config-context';

packages/tokens/runtime/resolve-theme.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ const runtime = require('./theme-runtime.cjs');
55
module.exports = {
66
resolveTheme: runtime.resolveTheme,
77
tokenKeyToCssVar: runtime.tokenKeyToCssVar,
8+
getThemeStylesheet: runtime.getThemeStylesheet,
89
};

0 commit comments

Comments
 (0)