Skip to content

Commit 882cc4a

Browse files
committed
feat(docs): add dark mode support for theme editor presets
Each preset now has separate light and dark seed values. Color derivation functions produce mode-appropriate tokens (dark backgrounds, lighter accents). Theme state watches for dark mode toggle via MutationObserver and re-applies seeds with the correct derivation automatically.
1 parent 82053e2 commit 882cc4a

5 files changed

Lines changed: 349 additions & 89 deletions

File tree

apps/docs/src/containers/theme-editor/components/preset-selector.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React from 'react';
2-
import { PRESETS, ThemePreset } from '../constants/presets';
2+
import { PRESETS, ThemePreset, getPresetSeeds } from '../constants/presets';
33

44
interface PresetSelectorProps {
55
activePresetId?: string;
6+
isDark: boolean;
67
onSelect: (presetSeeds: Record<string, string>, presetId: string) => void;
78
}
89

@@ -35,6 +36,7 @@ const PresetCard = ({
3536

3637
export const PresetSelector = ({
3738
activePresetId,
39+
isDark,
3840
onSelect,
3941
}: PresetSelectorProps): React.ReactElement => {
4042
return (
@@ -45,7 +47,7 @@ export const PresetSelector = ({
4547
key={preset.id}
4648
preset={preset}
4749
isActive={preset.id === activePresetId}
48-
onClick={() => onSelect(preset.seeds, preset.id)}
50+
onClick={() => onSelect(getPresetSeeds(preset, isDark), preset.id)}
4951
/>
5052
))}
5153
</div>

apps/docs/src/containers/theme-editor/constants/presets.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,49 @@ export interface ThemePreset {
22
id: string;
33
name: string;
44
nameZh: string;
5+
/** Light-mode seed overrides */
56
seeds: Record<string, string>;
7+
/** Dark-mode seed overrides (if omitted, only non-color seeds from `seeds` are used) */
8+
darkSeeds?: Record<string, string>;
69
swatches: string[];
710
}
811

12+
/** Keys that are safe to use in both light and dark modes (non-color) */
13+
const NON_COLOR_KEYS = new Set([
14+
'border-radius',
15+
'font-weight',
16+
'font-size-base',
17+
'font-size-sm',
18+
'font-size-lg',
19+
'line-height-base',
20+
'headings-font-weight',
21+
'height-sm',
22+
'height-md',
23+
'height-lg',
24+
'spacer',
25+
]);
26+
27+
/**
28+
* Given a preset and the current mode, return the seeds to apply.
29+
*/
30+
export function getPresetSeeds(
31+
preset: ThemePreset,
32+
isDark: boolean
33+
): Record<string, string> {
34+
if (!isDark) return preset.seeds;
35+
36+
// Dark mode: use darkSeeds if provided, otherwise only use non-color seeds
37+
if (preset.darkSeeds) return preset.darkSeeds;
38+
39+
const result: Record<string, string> = {};
40+
for (const [key, value] of Object.entries(preset.seeds)) {
41+
if (NON_COLOR_KEYS.has(key)) {
42+
result[key] = value;
43+
}
44+
}
45+
return result;
46+
}
47+
948
export const PRESETS: ThemePreset[] = [
1049
{
1150
id: 'default',
@@ -22,6 +61,10 @@ export const PRESETS: ThemePreset[] = [
2261
'color-primary': '#1677ff',
2362
'border-radius': '6px',
2463
},
64+
darkSeeds: {
65+
'color-primary': '#3b8eff',
66+
'border-radius': '6px',
67+
},
2568
swatches: ['#1677ff', '#e6f4ff', '#ffffff', '#d9d9d9'],
2669
},
2770
{
@@ -33,6 +76,13 @@ export const PRESETS: ThemePreset[] = [
3376
'color-bg': '#fafff5',
3477
'border-radius': '4px',
3578
},
79+
darkSeeds: {
80+
'color-primary': '#52c41a',
81+
'color-bg': '#0f1a0a',
82+
'color-text': 'rgba(220, 255, 200, 0.85)',
83+
'color-border': '#2d4a1e',
84+
'border-radius': '4px',
85+
},
3686
swatches: ['#389e0d', '#f6ffed', '#fafff5', '#d9d9d9'],
3787
},
3888
{
@@ -44,6 +94,13 @@ export const PRESETS: ThemePreset[] = [
4494
'color-bg': '#fffcf5',
4595
'border-radius': '6px',
4696
},
97+
darkSeeds: {
98+
'color-primary': '#faad14',
99+
'color-bg': '#1a1510',
100+
'color-text': 'rgba(255, 235, 200, 0.85)',
101+
'color-border': '#4a3a1e',
102+
'border-radius': '6px',
103+
},
47104
swatches: ['#fa8c16', '#fff7e6', '#fffcf5', '#e8d5b5'],
48105
},
49106
{
@@ -54,6 +111,10 @@ export const PRESETS: ThemePreset[] = [
54111
'color-primary': '#f43f5e',
55112
'border-radius': '8px',
56113
},
114+
darkSeeds: {
115+
'color-primary': '#fb7185',
116+
'border-radius': '8px',
117+
},
57118
swatches: ['#f43f5e', '#fff1f2', '#ffffff', '#e5d5d8'],
58119
},
59120
{
@@ -65,6 +126,13 @@ export const PRESETS: ThemePreset[] = [
65126
'color-bg': '#faf8ff',
66127
'border-radius': '8px',
67128
},
129+
darkSeeds: {
130+
'color-primary': '#a78bfa',
131+
'color-bg': '#110e1a',
132+
'color-text': 'rgba(220, 210, 255, 0.85)',
133+
'color-border': '#3d2e6e',
134+
'border-radius': '8px',
135+
},
68136
swatches: ['#8b5cf6', '#f0ebff', '#faf8ff', '#ddd6f3'],
69137
},
70138
{
@@ -76,6 +144,11 @@ export const PRESETS: ThemePreset[] = [
76144
'color-border': '#cbd5e1',
77145
'border-radius': '4px',
78146
},
147+
darkSeeds: {
148+
'color-primary': '#94a3b8',
149+
'color-border': '#334155',
150+
'border-radius': '4px',
151+
},
79152
swatches: ['#475569', '#f1f5f9', '#ffffff', '#cbd5e1'],
80153
},
81154
{
@@ -86,6 +159,10 @@ export const PRESETS: ThemePreset[] = [
86159
'color-primary': '#6366f1',
87160
'border-radius': '6px',
88161
},
162+
darkSeeds: {
163+
'color-primary': '#818cf8',
164+
'border-radius': '6px',
165+
},
89166
swatches: ['#6366f1', '#eef2ff', '#ffffff', '#c7d2fe'],
90167
},
91168
{
@@ -99,6 +176,13 @@ export const PRESETS: ThemePreset[] = [
99176
'color-border': '#d4c4b0',
100177
'border-radius': '6px',
101178
},
179+
darkSeeds: {
180+
'color-primary': '#b8956a',
181+
'color-bg': '#1a150f',
182+
'color-text': 'rgba(230, 210, 180, 0.85)',
183+
'color-border': '#4a3d2e',
184+
'border-radius': '6px',
185+
},
102186
swatches: ['#8B6F4E', '#f5ebe0', '#fdf8f3', '#d4c4b0'],
103187
},
104188
{
@@ -113,6 +197,14 @@ export const PRESETS: ThemePreset[] = [
113197
'border-radius': '2px',
114198
'font-weight': '400',
115199
},
200+
darkSeeds: {
201+
'color-primary': '#a8977d',
202+
'color-bg': '#18150f',
203+
'color-text': 'rgba(225, 215, 195, 0.85)',
204+
'color-border': '#453e30',
205+
'border-radius': '2px',
206+
'font-weight': '400',
207+
},
116208
swatches: ['#7c6f5b', '#f0e8d8', '#faf6ef', '#d5ccbb'],
117209
},
118210
{
@@ -126,6 +218,13 @@ export const PRESETS: ThemePreset[] = [
126218
'color-danger': '#ef4444',
127219
'border-radius': '12px',
128220
},
221+
darkSeeds: {
222+
'color-primary': '#22d3ee',
223+
'color-success': '#34d399',
224+
'color-warning': '#fbbf24',
225+
'color-danger': '#f87171',
226+
'border-radius': '12px',
227+
},
129228
swatches: ['#06b6d4', '#10b981', '#f59e0b', '#ef4444'],
130229
},
131230
{
@@ -138,6 +237,13 @@ export const PRESETS: ThemePreset[] = [
138237
'color-info': '#06b6d4',
139238
'border-radius': '8px',
140239
},
240+
darkSeeds: {
241+
'color-primary': '#2dd4bf',
242+
'color-bg': '#0a1a17',
243+
'color-info': '#22d3ee',
244+
'color-border': '#1e4a40',
245+
'border-radius': '8px',
246+
},
141247
swatches: ['#14b8a6', '#06b6d4', '#f0fdfa', '#b2dfdb'],
142248
},
143249
];

apps/docs/src/containers/theme-editor/hooks/use-theme-state.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,17 @@ function saveToStorage(seeds: Record<string, string>): void {
2323
}
2424
}
2525

26+
function detectDarkMode(): boolean {
27+
return document.documentElement.getAttribute('data-tiny-theme') === 'dark';
28+
}
29+
2630
export interface ThemeState {
2731
/** Raw seed overrides (only user-changed tokens) */
2832
seeds: Record<string, string>;
2933
/** All derived + seed tokens currently applied */
3034
applied: Record<string, string>;
35+
/** Whether dark mode is active */
36+
isDark: boolean;
3137
/** Update a single seed token */
3238
setSeed: (key: string, value: string) => void;
3339
/** Apply a full preset (replaces all seeds) */
@@ -42,16 +48,21 @@ export interface ThemeState {
4248

4349
export function useThemeState(): ThemeState {
4450
const [seeds, setSeeds] = useState<Record<string, string>>(loadFromStorage);
51+
const [isDark, setIsDark] = useState(detectDarkMode);
4552
const appliedRef = useRef<Record<string, string>>({});
53+
const seedsRef = useRef(seeds);
54+
seedsRef.current = seeds;
55+
56+
const applyAll = useCallback((newSeeds: Record<string, string>, dark?: boolean) => {
57+
const darkMode = dark ?? detectDarkMode();
4658

47-
const applyAll = useCallback((newSeeds: Record<string, string>) => {
4859
// Clear previous overrides
4960
if (Object.keys(appliedRef.current).length > 0) {
5061
clearAllTokenOverrides(appliedRef.current);
5162
}
5263

53-
// Derive all tokens from seeds
54-
const derived = deriveAllTokens(newSeeds);
64+
// Derive all tokens from seeds, using mode-appropriate derivation
65+
const derived = deriveAllTokens(newSeeds, darkMode);
5566
appliedRef.current = derived;
5667

5768
// Apply to DOM
@@ -75,9 +86,27 @@ export function useThemeState(): ThemeState {
7586
if (Object.keys(seeds).length > 0) {
7687
applyAll(seeds);
7788
}
78-
// Cleanup on unmount: don't clear — let theme persist across pages
7989
}, []); // eslint-disable-line react-hooks/exhaustive-deps
8090

91+
// Watch for dark mode changes and re-apply tokens
92+
useEffect(() => {
93+
const observer = new MutationObserver(() => {
94+
const dark = detectDarkMode();
95+
setIsDark(dark);
96+
// Re-derive with the same seeds but different mode
97+
if (Object.keys(seedsRef.current).length > 0) {
98+
applyAll(seedsRef.current, dark);
99+
}
100+
});
101+
102+
observer.observe(document.documentElement, {
103+
attributes: true,
104+
attributeFilter: ['data-tiny-theme'],
105+
});
106+
107+
return () => observer.disconnect();
108+
}, [applyAll]);
109+
81110
const setSeed = useCallback(
82111
(key: string, value: string) => {
83112
setSeeds((prev) => {
@@ -134,6 +163,7 @@ export function useThemeState(): ThemeState {
134163
return {
135164
seeds,
136165
applied: appliedRef.current,
166+
isDark,
137167
setSeed,
138168
applyPreset,
139169
reset,

apps/docs/src/containers/theme-editor/index.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React, { useState, useCallback } from 'react';
1+
import React, { useState, useCallback, useEffect, useRef } from 'react';
22
import { Button, Tabs } from '@tiny-design/react';
33
import { useThemeState } from './hooks/use-theme-state';
4+
import { PRESETS, getPresetSeeds } from './constants/presets';
45
import { ColorControls } from './components/color-controls';
56
import { TypographyControls } from './components/typography-controls';
67
import { DetailControls } from './components/detail-controls';
@@ -10,10 +11,23 @@ import { ExportDialog } from './components/export-dialog';
1011
import './theme-editor.scss';
1112

1213
const ThemeEditor = (): React.ReactElement => {
13-
const { seeds, applied, setSeed, applyPreset, reset, isOverridden, resetToken } =
14+
const { seeds, applied, isDark, setSeed, applyPreset, reset, isOverridden, resetToken } =
1415
useThemeState();
1516
const [exportVisible, setExportVisible] = useState(false);
1617
const [activePresetId, setActivePresetId] = useState<string | undefined>();
18+
const prevIsDarkRef = useRef(isDark);
19+
20+
// When dark mode toggles, re-apply the active preset with mode-appropriate seeds
21+
useEffect(() => {
22+
if (prevIsDarkRef.current !== isDark && activePresetId) {
23+
const preset = PRESETS.find((p) => p.id === activePresetId);
24+
if (preset) {
25+
const modeSeeds = getPresetSeeds(preset, isDark);
26+
applyPreset(modeSeeds);
27+
}
28+
}
29+
prevIsDarkRef.current = isDark;
30+
}, [isDark, activePresetId, applyPreset]);
1731

1832
const handlePresetSelect = useCallback(
1933
(presetSeeds: Record<string, string>, presetId: string) => {
@@ -63,6 +77,7 @@ const ThemeEditor = (): React.ReactElement => {
6377
<Tabs.Panel tab="Presets" tabKey="presets">
6478
<PresetSelector
6579
activePresetId={activePresetId}
80+
isDark={isDark}
6681
onSelect={handlePresetSelect}
6782
/>
6883
</Tabs.Panel>

0 commit comments

Comments
 (0)