@windmorph/react 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/checkbox/checkbox-tokens.d.ts +1 -1
- package/dist/components/chip/chip-tokens.d.ts +4 -4
- package/dist/components/date-picker/date-picker-styles.d.ts +6 -6
- package/dist/components/date-picker/date-picker-styles.js +5 -5
- package/dist/components/date-picker/date-picker-styles.js.map +1 -1
- package/dist/components/date-picker/date-picker-tokens.d.ts +1 -1
- package/dist/components/dialog/dialog-tokens.d.ts +1 -1
- package/dist/components/menu/menu-tokens.d.ts +1 -1
- package/dist/components/select/Select.js +7 -1
- package/dist/components/select/Select.js.map +1 -1
- package/dist/components/sheet/BottomSheet.js +6 -1
- package/dist/components/sheet/BottomSheet.js.map +1 -1
- package/dist/components/sheet/sheet-styles.js +4 -4
- package/dist/components/sheet/sheet-styles.js.map +1 -1
- package/dist/components/sheet/use-keyboard-inset.js +33 -0
- package/dist/components/sheet/use-keyboard-inset.js.map +1 -0
- package/dist/components/snackbar/snackbar-tokens.d.ts +1 -1
- package/dist/components/tooltip/tooltip-tokens.d.ts +1 -1
- package/dist/styles.compiled.css +1 -1
- package/dist/theme/ThemeProvider.d.ts +6 -0
- package/dist/theme/ThemeProvider.js +44 -11
- package/dist/theme/ThemeProvider.js.map +1 -1
- package/dist/theme/ThemeScript.d.ts +4 -1
- package/dist/theme/ThemeScript.js +4 -1
- package/dist/theme/ThemeScript.js.map +1 -1
- package/dist/theme/context.js +18 -3
- package/dist/theme/context.js.map +1 -1
- package/package.json +1 -1
|
@@ -51,6 +51,12 @@ export interface ThemeProviderProps {
|
|
|
51
51
|
* `getThemeFromCookies` or `ThemeScript` for flash-free server rendering. Nothing is written
|
|
52
52
|
* until the selection changes from the one the page loaded with, so a visitor who never
|
|
53
53
|
* picks a setting gets no cookie.
|
|
54
|
+
*
|
|
55
|
+
* The browser's saved selection is applied right after hydration. When it differs from
|
|
56
|
+
* what the server rendered, React renders any Suspense boundary still streaming (a Next.js
|
|
57
|
+
* `loading.tsx`) on the client, dropping its server HTML. A streaming server should read
|
|
58
|
+
* the cookie with `getThemeFromCookies` and pass every value as a `default*` prop; a
|
|
59
|
+
* development warning names the values when it doesn't.
|
|
54
60
|
* @default "cookie"
|
|
55
61
|
*/
|
|
56
62
|
storage?: ThemeStorage;
|
|
@@ -6,13 +6,6 @@ import { readStoredTheme, writeStoredTheme } from "./storage.js";
|
|
|
6
6
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
//#region src/theme/ThemeProvider.tsx
|
|
9
|
-
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
10
|
-
function subscribeToColorScheme(onChange) {
|
|
11
|
-
const query = window.matchMedia(DARK_QUERY);
|
|
12
|
-
query.addEventListener("change", onChange);
|
|
13
|
-
return () => query.removeEventListener("change", onChange);
|
|
14
|
-
}
|
|
15
|
-
const getSystemMode = () => window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
|
|
16
9
|
const subscribeToNothing = () => () => {};
|
|
17
10
|
/** Re-reads localStorage when another tab changes the theme. Cookies have no change event. */
|
|
18
11
|
function subscribeToStorage(storage, key, onChange) {
|
|
@@ -39,6 +32,49 @@ function useThemeValue(controlled, stored, defaultValue, onChange) {
|
|
|
39
32
|
onChange
|
|
40
33
|
])];
|
|
41
34
|
}
|
|
35
|
+
const THEME_STATE_KEYS = [
|
|
36
|
+
"theme",
|
|
37
|
+
"mode",
|
|
38
|
+
"contrast",
|
|
39
|
+
"motion",
|
|
40
|
+
"density"
|
|
41
|
+
];
|
|
42
|
+
/** Whether the page holds a Suspense boundary whose streamed HTML hasn't arrived or shown. */
|
|
43
|
+
function hasPendingStreamedBoundary() {
|
|
44
|
+
const walker = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_COMMENT);
|
|
45
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
46
|
+
const { data } = node;
|
|
47
|
+
if (data === "$?" || data === "$~") return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Development only: warns when the saved selection changes the theme right after hydration
|
|
53
|
+
* while streamed Suspense boundaries are still pending, since React then renders them on the
|
|
54
|
+
* client instead of hydrating them (2026-11-07).
|
|
55
|
+
*/
|
|
56
|
+
function useStreamedSelectionWarning(state, isHydrated) {
|
|
57
|
+
const [hydratedWith] = useState(() => isHydrated ? null : state);
|
|
58
|
+
const pendingAtHydration = useRef(false);
|
|
59
|
+
const checked = useRef(false);
|
|
60
|
+
useLayoutEffect(() => {
|
|
61
|
+
if (process.env.NODE_ENV === "production" || !hydratedWith) return;
|
|
62
|
+
pendingAtHydration.current = hasPendingStreamedBoundary();
|
|
63
|
+
}, [hydratedWith]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (process.env.NODE_ENV === "production" || !isHydrated || !hydratedWith) return;
|
|
66
|
+
if (checked.current) return;
|
|
67
|
+
checked.current = true;
|
|
68
|
+
const changed = THEME_STATE_KEYS.filter((key) => state[key] !== hydratedWith[key]);
|
|
69
|
+
if (!pendingAtHydration.current || changed.length === 0) return;
|
|
70
|
+
const describe = (values) => changed.map((key) => `${key} "${values[key]}"`).join(", ");
|
|
71
|
+
console.warn(`[@windmorph/react] ThemeProvider hydrated with the server's ${describe(hydratedWith)}, then applied the saved ${describe(state)}. A theme change right after hydration makes React render the Suspense boundaries still streaming on the client, dropping their server HTML. Give the server the saved selection: storage="cookie" with getThemeFromCookies from @windmorph/react/next, passing every value as a default* prop (defaultTheme, defaultMode, defaultContrast, defaultMotion, defaultDensity).`);
|
|
72
|
+
}, [
|
|
73
|
+
isHydrated,
|
|
74
|
+
hydratedWith,
|
|
75
|
+
state
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
42
78
|
/**
|
|
43
79
|
* Provides the colour theme, mode, contrast level, motion scheme and density to the app,
|
|
44
80
|
* mirrors them as `data-*` attributes on `<html>` and remembers the selection.
|
|
@@ -67,6 +103,7 @@ function ThemeProvider({ children, themes: themeList, theme: themeProp, defaultT
|
|
|
67
103
|
motion,
|
|
68
104
|
density
|
|
69
105
|
]);
|
|
106
|
+
useStreamedSelectionWarning(state, isHydrated);
|
|
70
107
|
const documentTheme = applyToDocument ? state : null;
|
|
71
108
|
useLayoutEffect(() => {
|
|
72
109
|
if (!isHydrated || !applyToDocument) return;
|
|
@@ -92,8 +129,6 @@ function ThemeProvider({ children, themes: themeList, theme: themeProp, defaultT
|
|
|
92
129
|
storageKey,
|
|
93
130
|
state
|
|
94
131
|
]);
|
|
95
|
-
const systemMode = useSyncExternalStore(subscribeToColorScheme, getSystemMode, () => "light");
|
|
96
|
-
const resolvedMode = mode === "system" ? systemMode : mode;
|
|
97
132
|
const setTheme = useCallback((next) => {
|
|
98
133
|
if (!themeNames.includes(next)) {
|
|
99
134
|
if (process.env.NODE_ENV !== "production") console.warn(`[@windmorph/react] setTheme("${next}") ignored: not one of ${themeNames.join(", ")}.`);
|
|
@@ -103,7 +138,6 @@ function ThemeProvider({ children, themes: themeList, theme: themeProp, defaultT
|
|
|
103
138
|
}, [themeNames, setThemeState]);
|
|
104
139
|
const value = useMemo(() => ({
|
|
105
140
|
...state,
|
|
106
|
-
resolvedMode,
|
|
107
141
|
themes: themeNames,
|
|
108
142
|
setTheme,
|
|
109
143
|
setMode,
|
|
@@ -112,7 +146,6 @@ function ThemeProvider({ children, themes: themeList, theme: themeProp, defaultT
|
|
|
112
146
|
setDensity
|
|
113
147
|
}), [
|
|
114
148
|
state,
|
|
115
|
-
resolvedMode,
|
|
116
149
|
themeNames,
|
|
117
150
|
setTheme,
|
|
118
151
|
setMode,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThemeProvider.js","names":[],"sources":["../../src/theme/ThemeProvider.tsx"],"sourcesContent":["'use client';\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from 'react';\nimport {\n BUILT_IN_THEME_NAMES,\n type BuiltInThemeName,\n type ColorMode,\n type ContrastLevel,\n type ResolvedColorMode,\n} from '../tokens/color';\nimport type { Density } from '../tokens/density';\nimport type { MotionScheme } from '../tokens/motion';\nimport {\n DocumentThemeContext,\n InheritedThemeContext,\n ThemeContext,\n ThemeScopeContext,\n type ThemeContextValue,\n} from './context';\nimport type { ThemeDefinition } from './create-theme';\nimport {\n DEFAULT_STORAGE_KEY,\n DEFAULT_THEME_STATE,\n parseThemeState,\n serializeThemeState,\n themeAttributes,\n type ThemeState,\n type ThemeStorage,\n} from './state';\nimport { readStoredTheme, writeStoredTheme } from './storage';\n\nexport interface ThemeProviderProps {\n children?: ReactNode;\n /**\n * Themes available to `setTheme`: built-in names and/or `createTheme()` results.\n * Defaults to all six built-in themes.\n */\n themes?: ReadonlyArray<BuiltInThemeName | ThemeDefinition>;\n /** Controlled colour theme. */\n theme?: string;\n /** Initial colour theme when uncontrolled. @default \"baseline\" */\n defaultTheme?: string;\n /** Called with the new colour theme when it changes. */\n onThemeChange?: (theme: string) => void;\n /** Controlled colour mode. */\n mode?: ColorMode;\n /** Initial colour mode when uncontrolled. @default \"system\" */\n defaultMode?: ColorMode;\n /** Called with the new colour mode when it changes. */\n onModeChange?: (mode: ColorMode) => void;\n /** Controlled contrast level. */\n contrast?: ContrastLevel;\n /** Initial contrast level when uncontrolled. @default \"standard\" */\n defaultContrast?: ContrastLevel;\n /** Called with the new contrast level when it changes. */\n onContrastChange?: (contrast: ContrastLevel) => void;\n /** Controlled motion scheme. */\n motion?: MotionScheme;\n /** Initial motion scheme when uncontrolled. @default \"expressive\" */\n defaultMotion?: MotionScheme;\n /** Called with the new motion scheme when it changes. */\n onMotionChange?: (motion: MotionScheme) => void;\n /**\n * Controlled web density, `0` to `-3`: each step takes 4px off the height of fields, menus\n * and other dense-capable components. M3 asks for density to be the reader's choice, not a\n * default, so offer it as a setting; keep the control that changes it at 48px or more.\n */\n density?: Density;\n /** Initial web density when uncontrolled. @default 0 */\n defaultDensity?: Density;\n /** Called with the new density when it changes. */\n onDensityChange?: (density: Density) => void;\n /**\n * Where the selection is remembered. Use `\"cookie\"` (default) with\n * `getThemeFromCookies` or `ThemeScript` for flash-free server rendering. Nothing is written\n * until the selection changes from the one the page loaded with, so a visitor who never\n * picks a setting gets no cookie.\n * @default \"cookie\"\n */\n storage?: ThemeStorage;\n /** Cookie or localStorage key. @default \"windmorph-theme\" */\n storageKey?: string;\n /**\n * Whether the theme attributes are written to `<html>`. Set to `false` when you\n * render them yourself or theme a subtree with `ThemeScope` instead.\n * @default true\n */\n applyToDocument?: boolean;\n}\n\nconst DARK_QUERY = '(prefers-color-scheme: dark)';\n\nfunction subscribeToColorScheme(onChange: () => void) {\n const query = window.matchMedia(DARK_QUERY);\n query.addEventListener('change', onChange);\n return () => query.removeEventListener('change', onChange);\n}\n\nconst getSystemMode = (): ResolvedColorMode =>\n window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light';\n\nconst subscribeToNothing = () => () => {};\n\n/** Re-reads localStorage when another tab changes the theme. Cookies have no change event. */\nfunction subscribeToStorage(storage: ThemeStorage, key: string, onChange: () => void) {\n if (storage !== 'local-storage') return () => {};\n const listener = (event: StorageEvent) => {\n if (event.key === key) onChange();\n };\n window.addEventListener('storage', listener);\n return () => window.removeEventListener('storage', listener);\n}\n\n/**\n * One theme dimension: the controlled prop wins, then the user's choice in this\n * session, then the stored selection, then the default.\n */\nfunction useThemeValue<T extends string | number>(\n controlled: T | undefined,\n stored: T | undefined,\n defaultValue: T,\n onChange: ((value: T) => void) | undefined,\n) {\n const [selected, setSelected] = useState<T | undefined>(undefined);\n const value = controlled ?? selected ?? stored ?? defaultValue;\n const setValue = useCallback(\n (next: T) => {\n if (controlled === undefined) setSelected(next);\n if (next !== value) onChange?.(next);\n },\n [controlled, value, onChange],\n );\n return [value, setValue] as const;\n}\n\n/**\n * Provides the colour theme, mode, contrast level, motion scheme and density to the app,\n * mirrors them as `data-*` attributes on `<html>` and remembers the selection.\n */\nexport function ThemeProvider({\n children,\n themes: themeList,\n theme: themeProp,\n defaultTheme = DEFAULT_THEME_STATE.theme,\n onThemeChange,\n mode: modeProp,\n defaultMode = DEFAULT_THEME_STATE.mode,\n onModeChange,\n contrast: contrastProp,\n defaultContrast = DEFAULT_THEME_STATE.contrast,\n onContrastChange,\n motion: motionProp,\n defaultMotion = DEFAULT_THEME_STATE.motion,\n onMotionChange,\n density: densityProp,\n defaultDensity = DEFAULT_THEME_STATE.density,\n onDensityChange,\n storage = 'cookie',\n storageKey = DEFAULT_STORAGE_KEY,\n applyToDocument = true,\n}: ThemeProviderProps) {\n const definitions = useMemo(\n () => (themeList ?? []).filter((entry): entry is ThemeDefinition => typeof entry !== 'string'),\n [themeList],\n );\n const themeNames = useMemo(\n () =>\n themeList\n ? themeList.map((entry) => (typeof entry === 'string' ? entry : entry.name))\n : BUILT_IN_THEME_NAMES,\n [themeList],\n );\n\n // The stored selection is external data that only exists in the browser. Reading it\n // through useSyncExternalStore renders the server snapshot during hydration and then\n // the stored value, without a hydration mismatch. `isHydrated` flips in the same pass.\n const storedRaw = useSyncExternalStore(\n useCallback(\n (onChange: () => void) => subscribeToStorage(storage, storageKey, onChange),\n [storage, storageKey],\n ),\n () => readStoredTheme(storage, storageKey),\n () => null,\n );\n const isHydrated = useSyncExternalStore(\n subscribeToNothing,\n () => true,\n () => false,\n );\n const stored = useMemo(() => parseThemeState(storedRaw, themeNames), [storedRaw, themeNames]);\n\n const [theme, setThemeState] = useThemeValue(\n themeProp,\n stored.theme,\n defaultTheme,\n onThemeChange,\n );\n const [mode, setMode] = useThemeValue(modeProp, stored.mode, defaultMode, onModeChange);\n const [contrast, setContrast] = useThemeValue(\n contrastProp,\n stored.contrast,\n defaultContrast,\n onContrastChange,\n );\n const [motion, setMotion] = useThemeValue(\n motionProp,\n stored.motion,\n defaultMotion,\n onMotionChange,\n );\n\n const [density, setDensity] = useThemeValue(\n densityProp,\n stored.density,\n defaultDensity,\n onDensityChange,\n );\n\n const state = useMemo<ThemeState>(\n () => ({ theme, mode, contrast, motion, density }),\n [theme, mode, contrast, motion, density],\n );\n\n // What `<html>` carries, for scopes and overlays to put only what differs on their elements.\n const documentTheme = applyToDocument ? state : null;\n\n useLayoutEffect(() => {\n if (!isHydrated || !applyToDocument) return;\n const root = document.documentElement;\n for (const [name, value] of Object.entries(themeAttributes(state))) {\n root.setAttribute(name, value);\n }\n }, [isHydrated, applyToDocument, state]);\n\n // Stored only once the selection changes from the one the page loaded with (the stored one,\n // or the defaults): a preference is remembered when someone sets it, and a visit that sets\n // nothing leaves no cookie behind.\n const loaded = useRef<string | null>(null);\n const changed = useRef(false);\n useEffect(() => {\n if (!isHydrated || storage === 'none') return;\n const serialized = serializeThemeState(state);\n loaded.current ??= serialized;\n if (!changed.current && serialized === loaded.current) return;\n changed.current = true;\n writeStoredTheme(storage, storageKey, state);\n }, [isHydrated, storage, storageKey, state]);\n\n const systemMode = useSyncExternalStore(\n subscribeToColorScheme,\n getSystemMode,\n (): ResolvedColorMode => 'light',\n );\n const resolvedMode = mode === 'system' ? systemMode : mode;\n\n const setTheme = useCallback(\n (next: string) => {\n if (!themeNames.includes(next)) {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n `[@windmorph/react] setTheme(\"${next}\") ignored: not one of ${themeNames.join(', ')}.`,\n );\n }\n return;\n }\n setThemeState(next);\n },\n [themeNames, setThemeState],\n );\n\n const value = useMemo<ThemeContextValue>(\n () => ({\n ...state,\n resolvedMode,\n themes: themeNames,\n setTheme,\n setMode,\n setContrast,\n setMotion,\n setDensity,\n }),\n [state, resolvedMode, themeNames, setTheme, setMode, setContrast, setMotion, setDensity],\n );\n\n return (\n <ThemeContext value={value}>\n <ThemeScopeContext value={state}>\n <DocumentThemeContext value={documentTheme}>\n <InheritedThemeContext value={documentTheme}>\n {definitions.map((definition) => (\n <style\n key={definition.name}\n href={`windmorph-theme-${definition.name}`}\n precedence=\"windmorph\"\n >\n {definition.css}\n </style>\n ))}\n {children}\n </InheritedThemeContext>\n </DocumentThemeContext>\n </ThemeScopeContext>\n </ThemeContext>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ThemeProvider.js","names":[],"sources":["../../src/theme/ThemeProvider.tsx"],"sourcesContent":["'use client';\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from 'react';\nimport {\n BUILT_IN_THEME_NAMES,\n type BuiltInThemeName,\n type ColorMode,\n type ContrastLevel,\n} from '../tokens/color';\nimport type { Density } from '../tokens/density';\nimport type { MotionScheme } from '../tokens/motion';\nimport {\n DocumentThemeContext,\n InheritedThemeContext,\n ThemeContext,\n ThemeScopeContext,\n type ThemeProviderValue,\n} from './context';\nimport type { ThemeDefinition } from './create-theme';\nimport {\n DEFAULT_STORAGE_KEY,\n DEFAULT_THEME_STATE,\n parseThemeState,\n serializeThemeState,\n themeAttributes,\n type ThemeState,\n type ThemeStorage,\n} from './state';\nimport { readStoredTheme, writeStoredTheme } from './storage';\n\nexport interface ThemeProviderProps {\n children?: ReactNode;\n /**\n * Themes available to `setTheme`: built-in names and/or `createTheme()` results.\n * Defaults to all six built-in themes.\n */\n themes?: ReadonlyArray<BuiltInThemeName | ThemeDefinition>;\n /** Controlled colour theme. */\n theme?: string;\n /** Initial colour theme when uncontrolled. @default \"baseline\" */\n defaultTheme?: string;\n /** Called with the new colour theme when it changes. */\n onThemeChange?: (theme: string) => void;\n /** Controlled colour mode. */\n mode?: ColorMode;\n /** Initial colour mode when uncontrolled. @default \"system\" */\n defaultMode?: ColorMode;\n /** Called with the new colour mode when it changes. */\n onModeChange?: (mode: ColorMode) => void;\n /** Controlled contrast level. */\n contrast?: ContrastLevel;\n /** Initial contrast level when uncontrolled. @default \"standard\" */\n defaultContrast?: ContrastLevel;\n /** Called with the new contrast level when it changes. */\n onContrastChange?: (contrast: ContrastLevel) => void;\n /** Controlled motion scheme. */\n motion?: MotionScheme;\n /** Initial motion scheme when uncontrolled. @default \"expressive\" */\n defaultMotion?: MotionScheme;\n /** Called with the new motion scheme when it changes. */\n onMotionChange?: (motion: MotionScheme) => void;\n /**\n * Controlled web density, `0` to `-3`: each step takes 4px off the height of fields, menus\n * and other dense-capable components. M3 asks for density to be the reader's choice, not a\n * default, so offer it as a setting; keep the control that changes it at 48px or more.\n */\n density?: Density;\n /** Initial web density when uncontrolled. @default 0 */\n defaultDensity?: Density;\n /** Called with the new density when it changes. */\n onDensityChange?: (density: Density) => void;\n /**\n * Where the selection is remembered. Use `\"cookie\"` (default) with\n * `getThemeFromCookies` or `ThemeScript` for flash-free server rendering. Nothing is written\n * until the selection changes from the one the page loaded with, so a visitor who never\n * picks a setting gets no cookie.\n *\n * The browser's saved selection is applied right after hydration. When it differs from\n * what the server rendered, React renders any Suspense boundary still streaming (a Next.js\n * `loading.tsx`) on the client, dropping its server HTML. A streaming server should read\n * the cookie with `getThemeFromCookies` and pass every value as a `default*` prop; a\n * development warning names the values when it doesn't.\n * @default \"cookie\"\n */\n storage?: ThemeStorage;\n /** Cookie or localStorage key. @default \"windmorph-theme\" */\n storageKey?: string;\n /**\n * Whether the theme attributes are written to `<html>`. Set to `false` when you\n * render them yourself or theme a subtree with `ThemeScope` instead.\n * @default true\n */\n applyToDocument?: boolean;\n}\n\nconst subscribeToNothing = () => () => {};\n\n/** Re-reads localStorage when another tab changes the theme. Cookies have no change event. */\nfunction subscribeToStorage(storage: ThemeStorage, key: string, onChange: () => void) {\n if (storage !== 'local-storage') return () => {};\n const listener = (event: StorageEvent) => {\n if (event.key === key) onChange();\n };\n window.addEventListener('storage', listener);\n return () => window.removeEventListener('storage', listener);\n}\n\n/**\n * One theme dimension: the controlled prop wins, then the user's choice in this\n * session, then the stored selection, then the default.\n */\nfunction useThemeValue<T extends string | number>(\n controlled: T | undefined,\n stored: T | undefined,\n defaultValue: T,\n onChange: ((value: T) => void) | undefined,\n) {\n const [selected, setSelected] = useState<T | undefined>(undefined);\n const value = controlled ?? selected ?? stored ?? defaultValue;\n const setValue = useCallback(\n (next: T) => {\n if (controlled === undefined) setSelected(next);\n if (next !== value) onChange?.(next);\n },\n [controlled, value, onChange],\n );\n return [value, setValue] as const;\n}\n\nconst THEME_STATE_KEYS = ['theme', 'mode', 'contrast', 'motion', 'density'] as const;\n\n/** Whether the page holds a Suspense boundary whose streamed HTML hasn't arrived or shown. */\nfunction hasPendingStreamedBoundary() {\n const walker = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_COMMENT);\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n // React's markers for a boundary still streaming (`$?`) or waiting to be revealed (`$~`).\n const { data } = node as Comment;\n if (data === '$?' || data === '$~') return true;\n }\n return false;\n}\n\n/**\n * Development only: warns when the saved selection changes the theme right after hydration\n * while streamed Suspense boundaries are still pending, since React then renders them on the\n * client instead of hydrating them (2026-11-07).\n */\nfunction useStreamedSelectionWarning(state: ThemeState, isHydrated: boolean) {\n // The state hydration rendered (the server's), or null on a client-only mount.\n const [hydratedWith] = useState(() => (isHydrated ? null : state));\n const pendingAtHydration = useRef(false);\n const checked = useRef(false);\n useLayoutEffect(() => {\n if (process.env.NODE_ENV === 'production' || !hydratedWith) return;\n pendingAtHydration.current = hasPendingStreamedBoundary();\n }, [hydratedWith]);\n useEffect(() => {\n if (process.env.NODE_ENV === 'production' || !isHydrated || !hydratedWith) return;\n if (checked.current) return;\n checked.current = true;\n const changed = THEME_STATE_KEYS.filter((key) => state[key] !== hydratedWith[key]);\n if (!pendingAtHydration.current || changed.length === 0) return;\n const describe = (values: ThemeState) =>\n changed.map((key) => `${key} \"${values[key]}\"`).join(', ');\n console.warn(\n `[@windmorph/react] ThemeProvider hydrated with the server's ${describe(hydratedWith)}, ` +\n `then applied the saved ${describe(state)}. A theme change right after hydration makes ` +\n 'React render the Suspense boundaries still streaming on the client, dropping their ' +\n 'server HTML. Give the server the saved selection: storage=\"cookie\" with ' +\n 'getThemeFromCookies from @windmorph/react/next, passing every value as a default* prop ' +\n '(defaultTheme, defaultMode, defaultContrast, defaultMotion, defaultDensity).',\n );\n }, [isHydrated, hydratedWith, state]);\n}\n\n/**\n * Provides the colour theme, mode, contrast level, motion scheme and density to the app,\n * mirrors them as `data-*` attributes on `<html>` and remembers the selection.\n */\nexport function ThemeProvider({\n children,\n themes: themeList,\n theme: themeProp,\n defaultTheme = DEFAULT_THEME_STATE.theme,\n onThemeChange,\n mode: modeProp,\n defaultMode = DEFAULT_THEME_STATE.mode,\n onModeChange,\n contrast: contrastProp,\n defaultContrast = DEFAULT_THEME_STATE.contrast,\n onContrastChange,\n motion: motionProp,\n defaultMotion = DEFAULT_THEME_STATE.motion,\n onMotionChange,\n density: densityProp,\n defaultDensity = DEFAULT_THEME_STATE.density,\n onDensityChange,\n storage = 'cookie',\n storageKey = DEFAULT_STORAGE_KEY,\n applyToDocument = true,\n}: ThemeProviderProps) {\n const definitions = useMemo(\n () => (themeList ?? []).filter((entry): entry is ThemeDefinition => typeof entry !== 'string'),\n [themeList],\n );\n const themeNames = useMemo(\n () =>\n themeList\n ? themeList.map((entry) => (typeof entry === 'string' ? entry : entry.name))\n : BUILT_IN_THEME_NAMES,\n [themeList],\n );\n\n // The stored selection is external data that only exists in the browser. Reading it\n // through useSyncExternalStore renders the server snapshot during hydration and then\n // the stored value, without a hydration mismatch. `isHydrated` flips in the same pass.\n // When the stored value differs from the server's, that second render changes the theme\n // after hydration (see `useStreamedSelectionWarning`), so a streaming server passes it in.\n const storedRaw = useSyncExternalStore(\n useCallback(\n (onChange: () => void) => subscribeToStorage(storage, storageKey, onChange),\n [storage, storageKey],\n ),\n () => readStoredTheme(storage, storageKey),\n () => null,\n );\n const isHydrated = useSyncExternalStore(\n subscribeToNothing,\n () => true,\n () => false,\n );\n const stored = useMemo(() => parseThemeState(storedRaw, themeNames), [storedRaw, themeNames]);\n\n const [theme, setThemeState] = useThemeValue(\n themeProp,\n stored.theme,\n defaultTheme,\n onThemeChange,\n );\n const [mode, setMode] = useThemeValue(modeProp, stored.mode, defaultMode, onModeChange);\n const [contrast, setContrast] = useThemeValue(\n contrastProp,\n stored.contrast,\n defaultContrast,\n onContrastChange,\n );\n const [motion, setMotion] = useThemeValue(\n motionProp,\n stored.motion,\n defaultMotion,\n onMotionChange,\n );\n\n const [density, setDensity] = useThemeValue(\n densityProp,\n stored.density,\n defaultDensity,\n onDensityChange,\n );\n\n const state = useMemo<ThemeState>(\n () => ({ theme, mode, contrast, motion, density }),\n [theme, mode, contrast, motion, density],\n );\n\n useStreamedSelectionWarning(state, isHydrated);\n\n // What `<html>` carries, for scopes and overlays to put only what differs on their elements.\n const documentTheme = applyToDocument ? state : null;\n\n useLayoutEffect(() => {\n if (!isHydrated || !applyToDocument) return;\n const root = document.documentElement;\n for (const [name, value] of Object.entries(themeAttributes(state))) {\n root.setAttribute(name, value);\n }\n }, [isHydrated, applyToDocument, state]);\n\n // Stored only once the selection changes from the one the page loaded with (the stored one,\n // or the defaults): a preference is remembered when someone sets it, and a visit that sets\n // nothing leaves no cookie behind.\n const loaded = useRef<string | null>(null);\n const changed = useRef(false);\n useEffect(() => {\n if (!isHydrated || storage === 'none') return;\n const serialized = serializeThemeState(state);\n loaded.current ??= serialized;\n if (!changed.current && serialized === loaded.current) return;\n changed.current = true;\n writeStoredTheme(storage, storageKey, state);\n }, [isHydrated, storage, storageKey, state]);\n\n const setTheme = useCallback(\n (next: string) => {\n if (!themeNames.includes(next)) {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n `[@windmorph/react] setTheme(\"${next}\") ignored: not one of ${themeNames.join(', ')}.`,\n );\n }\n return;\n }\n setThemeState(next);\n },\n [themeNames, setThemeState],\n );\n\n // `mode=\"system\"` is resolved to the device's mode in `useTheme`, not here: the device's mode\n // is known only after hydration, and a context value that changes then reaches the Suspense\n // boundaries still waiting for their streamed HTML, which React then renders on the client\n // instead, showing the page twice for a moment (2026-11-07).\n const value = useMemo<ThemeProviderValue>(\n () => ({\n ...state,\n themes: themeNames,\n setTheme,\n setMode,\n setContrast,\n setMotion,\n setDensity,\n }),\n [state, themeNames, setTheme, setMode, setContrast, setMotion, setDensity],\n );\n\n return (\n <ThemeContext value={value}>\n <ThemeScopeContext value={state}>\n <DocumentThemeContext value={documentTheme}>\n <InheritedThemeContext value={documentTheme}>\n {definitions.map((definition) => (\n <style\n key={definition.name}\n href={`windmorph-theme-${definition.name}`}\n precedence=\"windmorph\"\n >\n {definition.css}\n </style>\n ))}\n {children}\n </InheritedThemeContext>\n </DocumentThemeContext>\n </ThemeScopeContext>\n </ThemeContext>\n );\n}\n"],"mappings":"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"}
|
|
@@ -19,7 +19,10 @@ export declare function getThemeScriptSource({ defaults, storage, storageKey }?:
|
|
|
19
19
|
/**
|
|
20
20
|
* Prevents a theme flash on static or exported sites. Render it in `<head>` and add
|
|
21
21
|
* `suppressHydrationWarning` to `<html>`. With server rendering, prefer
|
|
22
|
-
* `getThemeFromCookies` from `@windmorph/react/next` instead
|
|
22
|
+
* `getThemeFromCookies` from `@windmorph/react/next` instead, passing every value to
|
|
23
|
+
* `ThemeProvider`: the script fixes the colours, but `ThemeProvider` still applies a saved
|
|
24
|
+
* selection only after hydration, which makes React render Suspense boundaries that are still
|
|
25
|
+
* streaming on the client.
|
|
23
26
|
*/
|
|
24
27
|
export declare function ThemeScript({ nonce, ...options }: ThemeScriptProps): import("react").JSX.Element;
|
|
25
28
|
//#endregion
|
|
@@ -30,7 +30,10 @@ function getThemeScriptSource({ defaults, storage = "cookie", storageKey = DEFAU
|
|
|
30
30
|
/**
|
|
31
31
|
* Prevents a theme flash on static or exported sites. Render it in `<head>` and add
|
|
32
32
|
* `suppressHydrationWarning` to `<html>`. With server rendering, prefer
|
|
33
|
-
* `getThemeFromCookies` from `@windmorph/react/next` instead
|
|
33
|
+
* `getThemeFromCookies` from `@windmorph/react/next` instead, passing every value to
|
|
34
|
+
* `ThemeProvider`: the script fixes the colours, but `ThemeProvider` still applies a saved
|
|
35
|
+
* selection only after hydration, which makes React render Suspense boundaries that are still
|
|
36
|
+
* streaming on the client.
|
|
34
37
|
*/
|
|
35
38
|
function ThemeScript({ nonce, ...options }) {
|
|
36
39
|
return /* @__PURE__ */ jsx("script", {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThemeScript.js","names":[],"sources":["../../src/theme/ThemeScript.tsx"],"sourcesContent":["import { COLOR_MODES, CONTRAST_LEVEL_NAMES } from '../tokens/color';\nimport { DENSITIES } from '../tokens/density';\nimport { MOTION_SCHEMES } from '../tokens/motion';\nimport {\n DEFAULT_STORAGE_KEY,\n DEFAULT_THEME_STATE,\n type ThemeState,\n type ThemeStorage,\n} from './state';\n\nexport interface ThemeScriptProps {\n /** Values used when nothing is stored. Must match the `ThemeProvider` defaults. */\n defaults?: Partial<ThemeState>;\n /** Must match `ThemeProvider`'s `storage`. @default \"cookie\" */\n storage?: Exclude<ThemeStorage, 'none'>;\n /** Must match `ThemeProvider`'s `storageKey`. */\n storageKey?: string;\n /** CSP nonce for the inline script. */\n nonce?: string;\n}\n\n/**\n * Builds the inline script source. It runs before first paint, reads the stored\n * selection and writes the `data-*` attributes on `<html>`, so static and exported\n * sites never flash the wrong theme.\n */\nexport function getThemeScriptSource({\n defaults,\n storage = 'cookie',\n storageKey = DEFAULT_STORAGE_KEY,\n}: Omit<ThemeScriptProps, 'nonce'> = {}): string {\n const config = {\n defaults: { ...DEFAULT_THEME_STATE, ...defaults },\n storage,\n key: storageKey,\n cookie: `(?:^|; )${storageKey.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}=([^;]*)`,\n allowed: {\n mode: COLOR_MODES,\n contrast: CONTRAST_LEVEL_NAMES,\n motion: MOTION_SCHEMES,\n density: DENSITIES.map(String),\n },\n };\n return `(function(c){try{var r=null;if(c.storage===\"cookie\"){var m=document.cookie.match(new RegExp(c.cookie));r=m&&m[1]}else{r=localStorage.getItem(c.key)}var p=new URLSearchParams(r?decodeURIComponent(r):\"\"),s={},k;for(k in c.defaults){var v=p.get(k);s[k]=v&&(k===\"theme\"?/^[a-z][a-z0-9-]*$/.test(v):c.allowed[k].indexOf(v)>-1)?v:c.defaults[k]}var e=document.documentElement;for(k in s)e.setAttribute(\"data-\"+k,s[k])}catch(_){}})(${JSON.stringify(config).replace(/</g, '\\\\u003c')})`;\n}\n\n/**\n * Prevents a theme flash on static or exported sites. Render it in `<head>` and add\n * `suppressHydrationWarning` to `<html>`. With server rendering, prefer\n * `getThemeFromCookies` from `@windmorph/react/next` instead.\n */\nexport function ThemeScript({ nonce, ...options }: ThemeScriptProps) {\n return (\n <script nonce={nonce} dangerouslySetInnerHTML={{ __html: getThemeScriptSource(options) }} />\n );\n}\n"],"mappings":";;;;;;;;;;;AA0BA,SAAgB,qBAAqB,EACnC,UACA,UAAU,UACV,aAAa,wBACsB,CAAC,GAAW;CAC/C,MAAM,SAAS;EACb,UAAU;GAAE,GAAG;GAAqB,GAAG;EAAS;EAChD;EACA,KAAK;EACL,QAAQ,WAAW,WAAW,QAAQ,uBAAuB,MAAM,EAAE;EACrE,SAAS;GACP,MAAM;GACN,UAAU;GACV,QAAQ;GACR,SAAS,UAAU,IAAI,MAAM;EAC/B;CACF;CACA,OAAO,2aAA2a,KAAK,UAAU,MAAM,CAAC,CAAC,QAAQ,MAAM,SAAS,EAAE;AACpe
|
|
1
|
+
{"version":3,"file":"ThemeScript.js","names":[],"sources":["../../src/theme/ThemeScript.tsx"],"sourcesContent":["import { COLOR_MODES, CONTRAST_LEVEL_NAMES } from '../tokens/color';\nimport { DENSITIES } from '../tokens/density';\nimport { MOTION_SCHEMES } from '../tokens/motion';\nimport {\n DEFAULT_STORAGE_KEY,\n DEFAULT_THEME_STATE,\n type ThemeState,\n type ThemeStorage,\n} from './state';\n\nexport interface ThemeScriptProps {\n /** Values used when nothing is stored. Must match the `ThemeProvider` defaults. */\n defaults?: Partial<ThemeState>;\n /** Must match `ThemeProvider`'s `storage`. @default \"cookie\" */\n storage?: Exclude<ThemeStorage, 'none'>;\n /** Must match `ThemeProvider`'s `storageKey`. */\n storageKey?: string;\n /** CSP nonce for the inline script. */\n nonce?: string;\n}\n\n/**\n * Builds the inline script source. It runs before first paint, reads the stored\n * selection and writes the `data-*` attributes on `<html>`, so static and exported\n * sites never flash the wrong theme.\n */\nexport function getThemeScriptSource({\n defaults,\n storage = 'cookie',\n storageKey = DEFAULT_STORAGE_KEY,\n}: Omit<ThemeScriptProps, 'nonce'> = {}): string {\n const config = {\n defaults: { ...DEFAULT_THEME_STATE, ...defaults },\n storage,\n key: storageKey,\n cookie: `(?:^|; )${storageKey.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}=([^;]*)`,\n allowed: {\n mode: COLOR_MODES,\n contrast: CONTRAST_LEVEL_NAMES,\n motion: MOTION_SCHEMES,\n density: DENSITIES.map(String),\n },\n };\n return `(function(c){try{var r=null;if(c.storage===\"cookie\"){var m=document.cookie.match(new RegExp(c.cookie));r=m&&m[1]}else{r=localStorage.getItem(c.key)}var p=new URLSearchParams(r?decodeURIComponent(r):\"\"),s={},k;for(k in c.defaults){var v=p.get(k);s[k]=v&&(k===\"theme\"?/^[a-z][a-z0-9-]*$/.test(v):c.allowed[k].indexOf(v)>-1)?v:c.defaults[k]}var e=document.documentElement;for(k in s)e.setAttribute(\"data-\"+k,s[k])}catch(_){}})(${JSON.stringify(config).replace(/</g, '\\\\u003c')})`;\n}\n\n/**\n * Prevents a theme flash on static or exported sites. Render it in `<head>` and add\n * `suppressHydrationWarning` to `<html>`. With server rendering, prefer\n * `getThemeFromCookies` from `@windmorph/react/next` instead, passing every value to\n * `ThemeProvider`: the script fixes the colours, but `ThemeProvider` still applies a saved\n * selection only after hydration, which makes React render Suspense boundaries that are still\n * streaming on the client.\n */\nexport function ThemeScript({ nonce, ...options }: ThemeScriptProps) {\n return (\n <script nonce={nonce} dangerouslySetInnerHTML={{ __html: getThemeScriptSource(options) }} />\n );\n}\n"],"mappings":";;;;;;;;;;;AA0BA,SAAgB,qBAAqB,EACnC,UACA,UAAU,UACV,aAAa,wBACsB,CAAC,GAAW;CAC/C,MAAM,SAAS;EACb,UAAU;GAAE,GAAG;GAAqB,GAAG;EAAS;EAChD;EACA,KAAK;EACL,QAAQ,WAAW,WAAW,QAAQ,uBAAuB,MAAM,EAAE;EACrE,SAAS;GACP,MAAM;GACN,UAAU;GACV,QAAQ;GACR,SAAS,UAAU,IAAI,MAAM;EAC/B;CACF;CACA,OAAO,2aAA2a,KAAK,UAAU,MAAM,CAAC,CAAC,QAAQ,MAAM,SAAS,EAAE;AACpe;;;;;;;;;AAUA,SAAgB,YAAY,EAAE,OAAO,GAAG,WAA6B;CACnE,OACE,oBAAC,UAAD;EAAe;EAAO,yBAAyB,EAAE,QAAQ,qBAAqB,OAAO,EAAE;CAAI,CAAA;AAE/F"}
|
package/dist/theme/context.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { DEFAULT_THEME_STATE } from "./state.js";
|
|
3
|
-
import { createContext, useContext } from "react";
|
|
3
|
+
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
|
4
4
|
//#region src/theme/context.ts
|
|
5
5
|
const ThemeContext = createContext(null);
|
|
6
|
+
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
7
|
+
function subscribeToColorScheme(onChange) {
|
|
8
|
+
const query = window.matchMedia(DARK_QUERY);
|
|
9
|
+
query.addEventListener("change", onChange);
|
|
10
|
+
return () => query.removeEventListener("change", onChange);
|
|
11
|
+
}
|
|
12
|
+
const getSystemMode = () => window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
|
|
13
|
+
const getServerSystemMode = () => "light";
|
|
6
14
|
/** The theme applied to the nearest `ThemeProvider` or `ThemeScope`. */
|
|
7
15
|
const ThemeScopeContext = createContext(DEFAULT_THEME_STATE);
|
|
8
16
|
/**
|
|
@@ -30,8 +38,15 @@ const DocumentThemeContext = createContext(null);
|
|
|
30
38
|
*/
|
|
31
39
|
function useTheme() {
|
|
32
40
|
const value = useContext(ThemeContext);
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
const systemMode = useSyncExternalStore(subscribeToColorScheme, getSystemMode, getServerSystemMode);
|
|
42
|
+
const mode = value?.mode;
|
|
43
|
+
const resolvedMode = mode === void 0 || mode === "system" ? systemMode : mode;
|
|
44
|
+
const theme = useMemo(() => value && {
|
|
45
|
+
...value,
|
|
46
|
+
resolvedMode
|
|
47
|
+
}, [value, resolvedMode]);
|
|
48
|
+
if (!theme) throw new Error("[@windmorph/react] useTheme() must be used inside <ThemeProvider>.");
|
|
49
|
+
return theme;
|
|
35
50
|
}
|
|
36
51
|
/** The classes and CSS variables of the `ThemeScope`s around this point (`ThemeScopeLayersContext`). */
|
|
37
52
|
function useThemeScopeLayers() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.js","names":[],"sources":["../../src/theme/context.ts"],"sourcesContent":["'use client';\n\nimport { createContext, useContext } from 'react';\nimport type { ColorMode, ContrastLevel, ResolvedColorMode } from '../tokens/color';\nimport type { Density } from '../tokens/density';\nimport type { MotionScheme } from '../tokens/motion';\nimport { DEFAULT_THEME_STATE, type ThemeState } from './state';\n\nexport interface ThemeContextValue extends ThemeState {\n /** `mode` with `\"system\"` resolved from `prefers-color-scheme`. */\n resolvedMode: ResolvedColorMode;\n /** Names of the themes available to `setTheme`. */\n themes: readonly string[];\n /** Switches the colour theme; a name not in `themes` is ignored (with a warning in development). */\n setTheme: (theme: string) => void;\n /** Switches the colour mode: `light`, `dark` or `system`. */\n setMode: (mode: ColorMode) => void;\n /** Switches the contrast level: `standard`, `medium` or `high`. */\n setContrast: (contrast: ContrastLevel) => void;\n /** Switches the motion scheme: `expressive` or `standard`. */\n setMotion: (motion: MotionScheme) => void;\n /** Switches the web density, `0` to `-3`. */\n setDensity: (density: Density) => void;\n}\n\nexport const ThemeContext = createContext<
|
|
1
|
+
{"version":3,"file":"context.js","names":[],"sources":["../../src/theme/context.ts"],"sourcesContent":["'use client';\n\nimport { createContext, useContext, useMemo, useSyncExternalStore } from 'react';\nimport type { ColorMode, ContrastLevel, ResolvedColorMode } from '../tokens/color';\nimport type { Density } from '../tokens/density';\nimport type { MotionScheme } from '../tokens/motion';\nimport { DEFAULT_THEME_STATE, type ThemeState } from './state';\n\nexport interface ThemeContextValue extends ThemeState {\n /** `mode` with `\"system\"` resolved from `prefers-color-scheme`. */\n resolvedMode: ResolvedColorMode;\n /** Names of the themes available to `setTheme`. */\n themes: readonly string[];\n /** Switches the colour theme; a name not in `themes` is ignored (with a warning in development). */\n setTheme: (theme: string) => void;\n /** Switches the colour mode: `light`, `dark` or `system`. */\n setMode: (mode: ColorMode) => void;\n /** Switches the contrast level: `standard`, `medium` or `high`. */\n setContrast: (contrast: ContrastLevel) => void;\n /** Switches the motion scheme: `expressive` or `standard`. */\n setMotion: (motion: MotionScheme) => void;\n /** Switches the web density, `0` to `-3`. */\n setDensity: (density: Density) => void;\n}\n\n/** What `ThemeProvider` provides: everything `useTheme` returns but `resolvedMode`. */\nexport type ThemeProviderValue = Omit<ThemeContextValue, 'resolvedMode'>;\n\nexport const ThemeContext = createContext<ThemeProviderValue | null>(null);\n\nconst DARK_QUERY = '(prefers-color-scheme: dark)';\n\nfunction subscribeToColorScheme(onChange: () => void) {\n const query = window.matchMedia(DARK_QUERY);\n query.addEventListener('change', onChange);\n return () => query.removeEventListener('change', onChange);\n}\n\nconst getSystemMode = (): ResolvedColorMode =>\n window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light';\n\nconst getServerSystemMode = (): ResolvedColorMode => 'light';\n\n/** The theme applied to the nearest `ThemeProvider` or `ThemeScope`. */\nexport const ThemeScopeContext = createContext<ThemeState>(DEFAULT_THEME_STATE);\n\n/** One `ThemeScope`'s class and the CSS variables (`--*` keys) of its `style`. */\nexport interface ThemeScopeLayer {\n className: string;\n variables: Readonly<Record<string, string>>;\n}\n\n/**\n * The classes and inline CSS variables of the `ThemeScope`s around this point, outermost first,\n * one layer per scope that sets either, so `Overlay` can repeat them around its portalled\n * content: component tokens an app sets through a scope's class or `style` then reach overlays\n * opened inside it (2026-10-17). Kept per scope (2026-10-24), so an inner scope's value wins\n * however each scope sets it, as on the page.\n */\nexport const ThemeScopeLayersContext = createContext<readonly ThemeScopeLayer[]>([]);\n\n/**\n * The theme the DOM already carries at this point through `data-*` attributes: the nearest\n * `ThemeScope`'s, or the `ThemeProvider`'s when it writes them on `<html>`; `null` when unknown.\n * A `ThemeScope` puts only the attributes that differ from it on its element.\n */\nexport const InheritedThemeContext = createContext<ThemeState | null>(null);\n\n/**\n * The theme `<html>` carries (the `ThemeProvider`'s, when it writes it there), for `Overlay`'s\n * wrapper in `document.body`; `null` when unknown.\n */\nexport const DocumentThemeContext = createContext<ThemeState | null>(null);\n\n/**\n * Reads and changes the app theme set by `ThemeProvider`.\n * @throws if called outside a `ThemeProvider`.\n */\nexport function useTheme(): ThemeContextValue {\n const value = useContext(ThemeContext);\n // Read here, in each component that asks, so the device's mode arriving after hydration\n // re-renders only those components and never changes the provider's context value.\n const systemMode = useSyncExternalStore(\n subscribeToColorScheme,\n getSystemMode,\n getServerSystemMode,\n );\n const mode = value?.mode;\n const resolvedMode = mode === undefined || mode === 'system' ? systemMode : mode;\n const theme = useMemo(() => value && { ...value, resolvedMode }, [value, resolvedMode]);\n if (!theme) throw new Error('[@windmorph/react] useTheme() must be used inside <ThemeProvider>.');\n return theme;\n}\n\n/** The classes and CSS variables of the `ThemeScope`s around this point (`ThemeScopeLayersContext`). */\nexport function useThemeScopeLayers(): readonly ThemeScopeLayer[] {\n return useContext(ThemeScopeLayersContext);\n}\n\n/** The theme state in effect at this point in the tree, including any `ThemeScope`. */\nexport function useThemeScope(): ThemeState {\n return useContext(ThemeScopeContext);\n}\n"],"mappings":";;;;AA4BA,MAAa,eAAe,cAAyC,IAAI;AAEzE,MAAM,aAAa;AAEnB,SAAS,uBAAuB,UAAsB;CACpD,MAAM,QAAQ,OAAO,WAAW,UAAU;CAC1C,MAAM,iBAAiB,UAAU,QAAQ;CACzC,aAAa,MAAM,oBAAoB,UAAU,QAAQ;AAC3D;AAEA,MAAM,sBACJ,OAAO,WAAW,UAAU,CAAC,CAAC,UAAU,SAAS;AAEnD,MAAM,4BAA+C;;AAGrD,MAAa,oBAAoB,cAA0B,mBAAmB;;;;;;;;AAe9E,MAAa,0BAA0B,cAA0C,CAAC,CAAC;;;;;;AAOnF,MAAa,wBAAwB,cAAiC,IAAI;;;;;AAM1E,MAAa,uBAAuB,cAAiC,IAAI;;;;;AAMzE,SAAgB,WAA8B;CAC5C,MAAM,QAAQ,WAAW,YAAY;CAGrC,MAAM,aAAa,qBACjB,wBACA,eACA,mBACF;CACA,MAAM,OAAO,OAAO;CACpB,MAAM,eAAe,SAAS,KAAA,KAAa,SAAS,WAAW,aAAa;CAC5E,MAAM,QAAQ,cAAc,SAAS;EAAE,GAAG;EAAO;CAAa,GAAG,CAAC,OAAO,YAAY,CAAC;CACtF,IAAI,CAAC,OAAO,MAAM,IAAI,MAAM,oEAAoE;CAChG,OAAO;AACT;;AAGA,SAAgB,sBAAkD;CAChE,OAAO,WAAW,uBAAuB;AAC3C;;AAGA,SAAgB,gBAA4B;CAC1C,OAAO,WAAW,iBAAiB;AACrC"}
|