@cronus-ui/theme 0.6.0
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/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/provider.d.ts +27 -0
- package/dist/provider.js +110 -0
- package/dist/theme-script.d.ts +34 -0
- package/dist/theme-script.js +41 -0
- package/dist/use-theme.d.ts +15 -0
- package/dist/use-theme.js +15 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cronus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# @cronus-ui/theme
|
|
2
|
+
|
|
3
|
+
The Cronus UI runtime theming engine — a React provider and hook that apply
|
|
4
|
+
[`@cronus-ui/tokens`](../tokens) to your app and let you re-theme any subtree live.
|
|
5
|
+
|
|
6
|
+
Theming happens entirely through CSS custom properties: switching theme, toggling
|
|
7
|
+
light/dark, or overriding a token (radius, primary, border, …) updates the whole
|
|
8
|
+
subtree instantly **without re-rendering the components below it**. This is what
|
|
9
|
+
makes brand portals, per-tenant styling, and visual preview builders cheap.
|
|
10
|
+
|
|
11
|
+
You need this package if you render `@cronus-ui/ui` components and want runtime theme
|
|
12
|
+
control, mode switching, or token overrides. (It is also the only supported way to
|
|
13
|
+
inject the `--cronus-*` variables when running on Tailwind v3.)
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
> Published on npm under the `@cronus-ui` scope.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
# npm
|
|
21
|
+
npm i @cronus-ui/theme @cronus-ui/tokens
|
|
22
|
+
# pnpm
|
|
23
|
+
pnpm add @cronus-ui/theme @cronus-ui/tokens
|
|
24
|
+
# bun
|
|
25
|
+
bun add @cronus-ui/theme @cronus-ui/tokens
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Prerequisites
|
|
29
|
+
|
|
30
|
+
- **React 19** (also works with React 18.3+) — `react` and `react-dom` are peer
|
|
31
|
+
dependencies.
|
|
32
|
+
- [`@cronus-ui/tokens`](../tokens) — the token data this provider applies (installed
|
|
33
|
+
alongside above; on Tailwind v4 also import `@cronus-ui/tokens/styles.css` once).
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Wrap your app at the framework root. Use `asRoot` so the theme/mode attributes
|
|
38
|
+
are written to `<html>` and the whole document is themed.
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// app/layout.tsx (Next.js App Router) — or your root component
|
|
42
|
+
import "@cronus-ui/tokens/styles.css";
|
|
43
|
+
import { CronusUIProvider } from "@cronus-ui/theme";
|
|
44
|
+
|
|
45
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
46
|
+
return (
|
|
47
|
+
<html lang="en">
|
|
48
|
+
<body>
|
|
49
|
+
<CronusUIProvider asRoot defaultThemeName="aurora" defaultModeName="dark">
|
|
50
|
+
{children}
|
|
51
|
+
</CronusUIProvider>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Avoiding a flash of the wrong theme
|
|
59
|
+
|
|
60
|
+
When you use `asRoot` with a `storageKey`, the provider restores the saved
|
|
61
|
+
theme/mode from `localStorage` in an effect that runs **after** first paint — so
|
|
62
|
+
a returning visitor whose saved choice differs from the defaults briefly sees the
|
|
63
|
+
default theme before it swaps (a "flash of the wrong theme", or FOUC).
|
|
64
|
+
|
|
65
|
+
Render `<CronusThemeScript>` in your document `<head>` to apply the saved
|
|
66
|
+
theme/mode **before** paint. It emits one tiny inline script that reads the same
|
|
67
|
+
`storageKey` and sets the `data-cronus-theme` / `data-cronus-mode` attributes and
|
|
68
|
+
the `dark` class on `<html>` exactly as the provider does.
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
// app/layout.tsx (Next.js App Router)
|
|
72
|
+
import { CronusThemeScript, CronusUIProvider } from "@cronus-ui/theme";
|
|
73
|
+
|
|
74
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
75
|
+
return (
|
|
76
|
+
// suppressHydrationWarning: the script mutates <html> before React hydrates.
|
|
77
|
+
<html lang="en" suppressHydrationWarning>
|
|
78
|
+
<head>
|
|
79
|
+
<CronusThemeScript
|
|
80
|
+
storageKey="cronus-ui-theme"
|
|
81
|
+
defaultThemeName="aurora"
|
|
82
|
+
defaultModeName="dark"
|
|
83
|
+
/>
|
|
84
|
+
</head>
|
|
85
|
+
<body>
|
|
86
|
+
<CronusUIProvider asRoot storageKey="cronus-ui-theme">
|
|
87
|
+
{children}
|
|
88
|
+
</CronusUIProvider>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Pass the **same** `storageKey` to both the script and the provider. Add
|
|
96
|
+
`suppressHydrationWarning` to `<html>` because the script changes those
|
|
97
|
+
attributes before hydration; without it React logs a hydration mismatch. For a
|
|
98
|
+
strict CSP, pass a `nonce` to `<CronusThemeScript nonce={nonce} />`.
|
|
99
|
+
|
|
100
|
+
### `useTheme`
|
|
101
|
+
|
|
102
|
+
Read and control the active theme from anywhere inside the provider.
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
"use client";
|
|
106
|
+
import { useTheme } from "@cronus-ui/theme";
|
|
107
|
+
|
|
108
|
+
export function ThemeControls() {
|
|
109
|
+
const { theme, mode, setTheme, setMode, toggleMode, setOverrides } = useTheme();
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div>
|
|
113
|
+
<button onClick={toggleMode}>Mode: {mode}</button>
|
|
114
|
+
<button onClick={() => setTheme(theme === "aurora" ? "neutral" : "aurora")}>
|
|
115
|
+
Theme: {theme}
|
|
116
|
+
</button>
|
|
117
|
+
{/* Re-theme the whole subtree at runtime — no component re-render: */}
|
|
118
|
+
<button
|
|
119
|
+
onClick={() =>
|
|
120
|
+
setOverrides({
|
|
121
|
+
radius: "16px",
|
|
122
|
+
primary: "oklch(0.685 0.169 237.3)",
|
|
123
|
+
fontDisplay: "Inter, sans-serif",
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
>
|
|
127
|
+
Apply brand theme
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Calling `useTheme()` outside a `<CronusUIProvider>` throws.
|
|
135
|
+
|
|
136
|
+
## API
|
|
137
|
+
|
|
138
|
+
### `<CronusUIProvider>`
|
|
139
|
+
|
|
140
|
+
| Prop | Type | Default | Description |
|
|
141
|
+
| ------------------ | ---------------- | ---------- | --------------------------------------------------------------------------- |
|
|
142
|
+
| `defaultThemeName` | `ThemeName` | `"aurora"` | Initial theme (`"aurora"` \| `"neutral"` \| `"midnight"` \| `"sunset"` \| `"emerald"`). |
|
|
143
|
+
| `defaultModeName` | `Mode` | `"dark"` | Initial mode (`"light"` \| `"dark"`). |
|
|
144
|
+
| `overrides` | `ThemeOverrides` | — | Seed per-scope token overrides (initial value only; later use `setOverrides`). |
|
|
145
|
+
| `asRoot` | `boolean` | `false` | Write attributes to `<html>` (whole document) instead of a wrapper `<div>`. |
|
|
146
|
+
| `storageKey` | `string` | — | Persist the theme/mode choice to `localStorage` under this key. |
|
|
147
|
+
| `className` | `string` | — | Extra classes for the wrapper `<div>` (ignored when `asRoot`). |
|
|
148
|
+
|
|
149
|
+
When `asRoot` is `false`, the provider renders a `<div>` that themes only its
|
|
150
|
+
subtree — useful for previews and isolated brand sections.
|
|
151
|
+
|
|
152
|
+
> `overrides` is reactive: change its **content** (e.g. from a controlled
|
|
153
|
+
> parent) and the themed element updates. A re-render that passes a new object of
|
|
154
|
+
> equal content does not loop or reset live overrides. `setOverrides` from
|
|
155
|
+
> `useTheme` still drives uncontrolled changes.
|
|
156
|
+
|
|
157
|
+
### `<CronusThemeScript>`
|
|
158
|
+
|
|
159
|
+
Inline anti-FOUC head script (see [Avoiding a flash of the wrong
|
|
160
|
+
theme](#avoiding-a-flash-of-the-wrong-theme)).
|
|
161
|
+
|
|
162
|
+
| Prop | Type | Default | Description |
|
|
163
|
+
| ------------------ | ----------- | ---------- | ----------------------------------------------------------------- |
|
|
164
|
+
| `storageKey` | `string` | — | Required. The same key passed to `<CronusUIProvider storageKey>`. |
|
|
165
|
+
| `defaultThemeName` | `ThemeName` | `"aurora"` | Theme applied when storage is empty or unreadable. |
|
|
166
|
+
| `defaultModeName` | `Mode` | `"dark"` | Mode applied when storage is empty or unreadable. |
|
|
167
|
+
| `nonce` | `string` | — | CSP nonce forwarded to the inline `<script>`. |
|
|
168
|
+
|
|
169
|
+
### `useTheme(): ThemeContextValue`
|
|
170
|
+
|
|
171
|
+
Returns `{ theme, mode, overrides, setTheme, setMode, toggleMode, setOverrides }`.
|
|
172
|
+
`ThemeName`, `Mode`, and `ThemeOverrides` come from [`@cronus-ui/tokens`](../tokens).
|
|
173
|
+
|
|
174
|
+
## How it works
|
|
175
|
+
|
|
176
|
+
`overrides` are converted to a `{ "--cronus-*": value }` style object
|
|
177
|
+
(via `@cronus-ui/tokens`) and set on the themed element; the `@cronus-ui/tokens` Tailwind
|
|
178
|
+
bridge maps utilities like `bg-primary` and `rounded-lg` onto those variables.
|
|
179
|
+
Because everything resolves through CSS variables, overriding a token restyles the
|
|
180
|
+
subtree with no React re-render of the components it contains.
|
|
181
|
+
|
|
182
|
+
## Related packages
|
|
183
|
+
|
|
184
|
+
- [`@cronus-ui/tokens`](../tokens) — the token source this provider applies.
|
|
185
|
+
- [`@cronus-ui/ui`](../ui) — the components that render against the applied tokens.
|
|
186
|
+
- See the [docs site](../../apps/www) for the live theme builder.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { CronusUIProviderProps } from "./provider.js";
|
|
2
|
+
export { CronusUIProvider } from "./provider.js";
|
|
3
|
+
export type { CronusThemeScriptProps } from "./theme-script.js";
|
|
4
|
+
export { CronusThemeScript } from "./theme-script.js";
|
|
5
|
+
export type { ThemeContextValue } from "./use-theme.js";
|
|
6
|
+
export { useOptionalThemeMode, useTheme } from "./use-theme.js";
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Mode, type ThemeName, type ThemeOverrides } from "@cronus-ui/tokens";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
export interface CronusUIProviderProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** Initial theme. @default "aurora" */
|
|
6
|
+
defaultThemeName?: ThemeName;
|
|
7
|
+
/** Initial mode. @default "dark" */
|
|
8
|
+
defaultModeName?: Mode;
|
|
9
|
+
/** Per-scope token overrides (radius, primary, border, ...). */
|
|
10
|
+
overrides?: ThemeOverrides;
|
|
11
|
+
/**
|
|
12
|
+
* When true, attributes are written to <html> so the whole document is
|
|
13
|
+
* themed. When false (default), they are written to a wrapper <div> so a
|
|
14
|
+
* subtree can be themed independently (useful for the ThemeBuilder preview).
|
|
15
|
+
*/
|
|
16
|
+
asRoot?: boolean;
|
|
17
|
+
/** Persist theme/mode choice to localStorage under this key. */
|
|
18
|
+
storageKey?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Themes its subtree purely via CSS custom properties — no per-component
|
|
23
|
+
* re-render, no context churn beyond the controls themselves. Overriding
|
|
24
|
+
* `radius`, `primary`, `border`, etc. updates the entire subtree instantly.
|
|
25
|
+
*/
|
|
26
|
+
export declare function CronusUIProvider({ children, defaultThemeName, defaultModeName, overrides, asRoot, storageKey, className, }: CronusUIProviderProps): import("react").JSX.Element;
|
|
27
|
+
//# sourceMappingURL=provider.d.ts.map
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { defaultMode, defaultTheme, tokensToCssVars, } from "@cronus-ui/tokens";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { ThemeContext } from "./use-theme.js";
|
|
6
|
+
function applyRootAttributes(theme, mode) {
|
|
7
|
+
if (typeof document === "undefined")
|
|
8
|
+
return;
|
|
9
|
+
const el = document.documentElement;
|
|
10
|
+
el.dataset.cronusTheme = theme;
|
|
11
|
+
el.dataset.cronusMode = mode;
|
|
12
|
+
el.classList.toggle("dark", mode === "dark");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Themes its subtree purely via CSS custom properties — no per-component
|
|
16
|
+
* re-render, no context churn beyond the controls themselves. Overriding
|
|
17
|
+
* `radius`, `primary`, `border`, etc. updates the entire subtree instantly.
|
|
18
|
+
*/
|
|
19
|
+
export function CronusUIProvider({ children, defaultThemeName = defaultTheme, defaultModeName = defaultMode, overrides, asRoot = false, storageKey, className, }) {
|
|
20
|
+
const [theme, setThemeState] = useState(defaultThemeName);
|
|
21
|
+
const [mode, setModeState] = useState(defaultModeName);
|
|
22
|
+
// `overrides` seeds the initial value; the effect below keeps it in sync when
|
|
23
|
+
// the prop's CONTENT changes (controlled usage), while setScopedOverrides
|
|
24
|
+
// still drives uncontrolled changes via setOverrides() (e.g. the
|
|
25
|
+
// ThemeBuilder). Syncing on a stable serialization — not the object identity —
|
|
26
|
+
// means a re-render with an equal-content new reference neither loops nor
|
|
27
|
+
// resets the live overrides.
|
|
28
|
+
const [scopedOverrides, setScopedOverrides] = useState(() => overrides ?? {});
|
|
29
|
+
// Make the controlled `overrides` prop reactive. Keying on the JSON instead of
|
|
30
|
+
// the object reference avoids the infinite loop an inline `overrides={{...}}`
|
|
31
|
+
// (new reference every render) would otherwise cause.
|
|
32
|
+
const overridesKey = JSON.stringify(overrides ?? {});
|
|
33
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `overridesKey` is the stable serialization of `overrides`; depending on the object reference is exactly what we must avoid.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setScopedOverrides(overrides ?? {});
|
|
36
|
+
}, [overridesKey]);
|
|
37
|
+
// Hydrate from storage once on mount.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!storageKey || typeof window === "undefined")
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
43
|
+
if (!raw)
|
|
44
|
+
return;
|
|
45
|
+
const saved = JSON.parse(raw);
|
|
46
|
+
if (saved.theme)
|
|
47
|
+
setThemeState(saved.theme);
|
|
48
|
+
if (saved.mode)
|
|
49
|
+
setModeState(saved.mode);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// ignore malformed storage
|
|
53
|
+
}
|
|
54
|
+
}, [storageKey]);
|
|
55
|
+
const persist = useCallback((next) => {
|
|
56
|
+
if (!storageKey || typeof window === "undefined")
|
|
57
|
+
return;
|
|
58
|
+
try {
|
|
59
|
+
window.localStorage.setItem(storageKey, JSON.stringify(next));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// ignore quota / privacy errors
|
|
63
|
+
}
|
|
64
|
+
}, [storageKey]);
|
|
65
|
+
const setTheme = useCallback((next) => {
|
|
66
|
+
setThemeState(next);
|
|
67
|
+
persist({ theme: next, mode });
|
|
68
|
+
}, [mode, persist]);
|
|
69
|
+
const setMode = useCallback((next) => {
|
|
70
|
+
setModeState(next);
|
|
71
|
+
persist({ theme, mode: next });
|
|
72
|
+
}, [theme, persist]);
|
|
73
|
+
const toggleMode = useCallback(() => {
|
|
74
|
+
setModeState((prev) => {
|
|
75
|
+
const next = prev === "dark" ? "light" : "dark";
|
|
76
|
+
persist({ theme, mode: next });
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
}, [theme, persist]);
|
|
80
|
+
// Sync <html> attributes + overrides when used as the document root.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!asRoot)
|
|
83
|
+
return;
|
|
84
|
+
applyRootAttributes(theme, mode);
|
|
85
|
+
if (typeof document === "undefined")
|
|
86
|
+
return;
|
|
87
|
+
const el = document.documentElement;
|
|
88
|
+
const vars = tokensToCssVars(scopedOverrides);
|
|
89
|
+
for (const [prop, val] of Object.entries(vars))
|
|
90
|
+
el.style.setProperty(prop, val);
|
|
91
|
+
return () => {
|
|
92
|
+
for (const prop of Object.keys(vars))
|
|
93
|
+
el.style.removeProperty(prop);
|
|
94
|
+
};
|
|
95
|
+
}, [asRoot, theme, mode, scopedOverrides]);
|
|
96
|
+
const value = useMemo(() => ({
|
|
97
|
+
theme,
|
|
98
|
+
mode,
|
|
99
|
+
overrides: scopedOverrides,
|
|
100
|
+
setTheme,
|
|
101
|
+
setMode,
|
|
102
|
+
toggleMode,
|
|
103
|
+
setOverrides: setScopedOverrides,
|
|
104
|
+
}), [theme, mode, scopedOverrides, setTheme, setMode, toggleMode]);
|
|
105
|
+
const style = useMemo(() => tokensToCssVars(scopedOverrides), [scopedOverrides]);
|
|
106
|
+
return (_jsx(ThemeContext.Provider, { value: value, children: asRoot ? (children) : (_jsx("div", { "data-cronus-theme": theme, "data-cronus-mode": mode, className: [mode === "dark" ? "dark" : null, "text-fg", className]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join(" "), style: style, children: children })) }));
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=provider.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Mode, type ThemeName } from "@cronus-ui/tokens";
|
|
2
|
+
export interface CronusThemeScriptProps {
|
|
3
|
+
/**
|
|
4
|
+
* The same `storageKey` passed to `<CronusUIProvider>`. The script reads the
|
|
5
|
+
* persisted `{ theme, mode }` from `localStorage[storageKey]`.
|
|
6
|
+
*/
|
|
7
|
+
storageKey: string;
|
|
8
|
+
/** Theme to apply when storage is empty/unreadable. @default "aurora" */
|
|
9
|
+
defaultThemeName?: ThemeName;
|
|
10
|
+
/** Mode to apply when storage is empty/unreadable. @default "dark" */
|
|
11
|
+
defaultModeName?: Mode;
|
|
12
|
+
/** CSP nonce forwarded to the inline `<script nonce>`. */
|
|
13
|
+
nonce?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Inline head script that applies the persisted theme/mode to `<html>` BEFORE
|
|
17
|
+
* first paint, eliminating the flash of the default theme that otherwise occurs
|
|
18
|
+
* while `<CronusUIProvider asRoot storageKey>` hydrates from `localStorage` in a
|
|
19
|
+
* post-paint effect.
|
|
20
|
+
*
|
|
21
|
+
* Place it inside `<head>` (above the app) and add `suppressHydrationWarning` to
|
|
22
|
+
* the `<html>` element, since this mutates `<html>` before React hydrates.
|
|
23
|
+
*
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <html lang="en" suppressHydrationWarning>
|
|
26
|
+
* <head>
|
|
27
|
+
* <CronusThemeScript storageKey="theme" defaultThemeName="aurora" defaultModeName="dark" />
|
|
28
|
+
* </head>
|
|
29
|
+
* ...
|
|
30
|
+
* </html>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function CronusThemeScript({ storageKey, defaultThemeName, defaultModeName, nonce, }: CronusThemeScriptProps): import("react").JSX.Element;
|
|
34
|
+
//# sourceMappingURL=theme-script.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { defaultMode, defaultTheme } from "@cronus-ui/tokens";
|
|
3
|
+
/**
|
|
4
|
+
* Builds the dependency-free IIFE source. The body mirrors the provider's
|
|
5
|
+
* `applyRootAttributes` byte-for-byte (dataset.cronusTheme / dataset.cronusMode /
|
|
6
|
+
* classList.toggle("dark", …)) so the pre-paint state matches what React would
|
|
7
|
+
* compute on hydration. All interpolated values are `JSON.stringify`-encoded so
|
|
8
|
+
* a storage key or default containing quotes can't break out of the script.
|
|
9
|
+
*/
|
|
10
|
+
function buildScript(storageKey, defaultThemeName, defaultModeName) {
|
|
11
|
+
// Defaults are applied first and the storage read overrides them, so a quota /
|
|
12
|
+
// malformed-JSON throw still leaves <html> on the SSR default the provider
|
|
13
|
+
// reconciles to on hydration (rather than a bare, attribute-less root).
|
|
14
|
+
return `(function(){var d=document.documentElement,t=${JSON.stringify(defaultThemeName)},m=${JSON.stringify(defaultModeName)};try{var s=localStorage.getItem(${JSON.stringify(storageKey)});if(s){var p=JSON.parse(s);if(p&&p.theme)t=p.theme;if(p&&p.mode)m=p.mode;}}catch(e){}d.dataset.cronusTheme=t;d.dataset.cronusMode=m;d.classList.toggle("dark",m==="dark");})();`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Inline head script that applies the persisted theme/mode to `<html>` BEFORE
|
|
18
|
+
* first paint, eliminating the flash of the default theme that otherwise occurs
|
|
19
|
+
* while `<CronusUIProvider asRoot storageKey>` hydrates from `localStorage` in a
|
|
20
|
+
* post-paint effect.
|
|
21
|
+
*
|
|
22
|
+
* Place it inside `<head>` (above the app) and add `suppressHydrationWarning` to
|
|
23
|
+
* the `<html>` element, since this mutates `<html>` before React hydrates.
|
|
24
|
+
*
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <html lang="en" suppressHydrationWarning>
|
|
27
|
+
* <head>
|
|
28
|
+
* <CronusThemeScript storageKey="theme" defaultThemeName="aurora" defaultModeName="dark" />
|
|
29
|
+
* </head>
|
|
30
|
+
* ...
|
|
31
|
+
* </html>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function CronusThemeScript({ storageKey, defaultThemeName = defaultTheme, defaultModeName = defaultMode, nonce, }) {
|
|
35
|
+
return (_jsx("script", {
|
|
36
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: required to inline a pre-paint anti-FOUC script; all interpolated values are JSON.stringify-encoded.
|
|
37
|
+
dangerouslySetInnerHTML: {
|
|
38
|
+
__html: buildScript(storageKey, defaultThemeName, defaultModeName),
|
|
39
|
+
}, nonce: nonce }));
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=theme-script.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Mode, ThemeName, ThemeOverrides } from "@cronus-ui/tokens";
|
|
2
|
+
export interface ThemeContextValue {
|
|
3
|
+
theme: ThemeName;
|
|
4
|
+
mode: Mode;
|
|
5
|
+
overrides: ThemeOverrides;
|
|
6
|
+
setTheme: (theme: ThemeName) => void;
|
|
7
|
+
setMode: (mode: Mode) => void;
|
|
8
|
+
toggleMode: () => void;
|
|
9
|
+
setOverrides: (overrides: ThemeOverrides) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare const ThemeContext: import("react").Context<ThemeContextValue | null>;
|
|
12
|
+
export declare function useTheme(): ThemeContextValue;
|
|
13
|
+
/** Mode from the nearest provider, or null outside one. */
|
|
14
|
+
export declare function useOptionalThemeMode(): Mode | null;
|
|
15
|
+
//# sourceMappingURL=use-theme.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
export const ThemeContext = createContext(null);
|
|
4
|
+
export function useTheme() {
|
|
5
|
+
const ctx = useContext(ThemeContext);
|
|
6
|
+
if (!ctx) {
|
|
7
|
+
throw new Error("useTheme must be used within a <CronusUIProvider>");
|
|
8
|
+
}
|
|
9
|
+
return ctx;
|
|
10
|
+
}
|
|
11
|
+
/** Mode from the nearest provider, or null outside one. */
|
|
12
|
+
export function useOptionalThemeMode() {
|
|
13
|
+
return useContext(ThemeContext)?.mode ?? null;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=use-theme.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cronus-ui/theme",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Cronus runtime theming engine — CronusUIProvider + useTheme (CSS-var only, no re-render).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Cronus",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/pedrogbraz/cronus-ui.git",
|
|
20
|
+
"directory": "packages/theme"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://aicronus.com",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/pedrogbraz/cronus-ui/issues"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"cronus",
|
|
28
|
+
"design-system",
|
|
29
|
+
"react",
|
|
30
|
+
"tailwind",
|
|
31
|
+
"ui",
|
|
32
|
+
"components"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.md",
|
|
41
|
+
"!dist/**/*.map"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
+
"prepublishOnly": "tsc -p tsconfig.json"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@cronus-ui/tokens": "0.6.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"react": "^18.3.0 || ^19.0.0",
|
|
53
|
+
"react-dom": "^18.3.0 || ^19.0.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/react": "^19.0.0",
|
|
57
|
+
"@types/react-dom": "^19.0.0",
|
|
58
|
+
"react": "^19.2.0",
|
|
59
|
+
"react-dom": "^19.2.0",
|
|
60
|
+
"typescript": "^6.0.3",
|
|
61
|
+
"vitest": "^4.1.9"
|
|
62
|
+
}
|
|
63
|
+
}
|