@nebutra/tokens 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/.turbo/turbo-build.log +8 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/AGENTS.md +5 -2
- package/CHANGELOG.md +33 -0
- package/DESIGN.md +30 -6
- package/LICENSE +21 -676
- package/README.md +41 -8
- package/brands/gsap/brand.json +180 -0
- package/brands/linear/brand.json +202 -0
- package/brands/notion/brand.json +189 -0
- package/brands/raycast/brand.json +167 -0
- package/brands/stripe/brand.json +183 -0
- package/brands/vanta/brand.json +198 -0
- package/brands/vercel/brand.json +207 -0
- package/package.json +18 -9
- package/recipe.css +114 -0
- package/scripts/compile-brand-run.ts +79 -0
- package/scripts/compile-brand.mjs +25 -0
- package/scripts/emit-skins-run.ts +62 -0
- package/scripts/emit-skins.mjs +31 -0
- package/scripts/sync-styles.mjs +29 -0
- package/skins/README.md +151 -0
- package/skins/gsap.css +247 -0
- package/skins/linear.css +282 -0
- package/skins/notion.css +248 -0
- package/skins/raycast.css +272 -0
- package/skins/stripe.css +248 -0
- package/skins/vanta.css +255 -0
- package/skins/vercel.css +283 -0
- package/src/brand-package/__tests__/compile-refero.test.ts +427 -0
- package/src/brand-package/__tests__/emit-css.test.ts +130 -0
- package/src/brand-package/apply-brand.ts +92 -0
- package/src/brand-package/compile-helpers.ts +170 -0
- package/src/brand-package/compile-refero.ts +69 -0
- package/src/brand-package/emit-css.ts +468 -0
- package/src/brand-package/hex-to-hsl.ts +100 -0
- package/src/brand-package/index.ts +71 -0
- package/src/brand-package/infer-recipe.ts +198 -0
- package/src/brand-package/normalize.ts +295 -0
- package/src/brand-package/presets/context.ts +17 -0
- package/src/brand-package/presets/generic.ts +214 -0
- package/src/brand-package/presets/gsap.ts +146 -0
- package/src/brand-package/presets/index.ts +31 -0
- package/src/brand-package/presets/linear.ts +183 -0
- package/src/brand-package/presets/notion.ts +208 -0
- package/src/brand-package/presets/raycast.ts +138 -0
- package/src/brand-package/presets/recipe.ts +61 -0
- package/src/brand-package/presets/stripe.ts +207 -0
- package/src/brand-package/presets/vanta.ts +220 -0
- package/src/brand-package/presets/vercel.ts +187 -0
- package/src/brand-package/types.ts +285 -0
- package/src/brand-package/use-brand.ts +207 -0
- package/src/brand-package/validate.ts +108 -0
- package/src/index.ts +31 -1
- package/src/theme-provider.tsx +21 -6
- package/styles.css +157 -79
- package/turbo.json +15 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
type ApplyBrandOptions,
|
|
6
|
+
applyBrandPackage,
|
|
7
|
+
clearBrand,
|
|
8
|
+
getActiveBrandId,
|
|
9
|
+
restorePersistedBrand,
|
|
10
|
+
} from "./apply-brand";
|
|
11
|
+
import { emitBrandCss } from "./emit-css";
|
|
12
|
+
import type { BrandPackage } from "./types";
|
|
13
|
+
|
|
14
|
+
export interface UseBrandOptions {
|
|
15
|
+
/** Restore from localStorage on mount */
|
|
16
|
+
autoRestore?: boolean;
|
|
17
|
+
/** Persist apply/clear to localStorage */
|
|
18
|
+
persist?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseBrandResult {
|
|
22
|
+
brand: BrandPackage | null;
|
|
23
|
+
brandId: string | null;
|
|
24
|
+
apply: (brand: BrandPackage) => void;
|
|
25
|
+
clear: () => void;
|
|
26
|
+
restore: () => BrandPackage | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create Center / app-level brand state for the host document.
|
|
31
|
+
*/
|
|
32
|
+
export function useBrand(options: UseBrandOptions = {}): UseBrandResult {
|
|
33
|
+
const { autoRestore = false, persist = true } = options;
|
|
34
|
+
const [brand, setBrand] = useState<BrandPackage | null>(null);
|
|
35
|
+
const [brandId, setBrandId] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!autoRestore) return;
|
|
39
|
+
const restored = restorePersistedBrand({ persist: false });
|
|
40
|
+
if (restored) {
|
|
41
|
+
setBrand(restored);
|
|
42
|
+
setBrandId(restored.id);
|
|
43
|
+
} else {
|
|
44
|
+
setBrandId(getActiveBrandId());
|
|
45
|
+
}
|
|
46
|
+
}, [autoRestore]);
|
|
47
|
+
|
|
48
|
+
const apply = useCallback(
|
|
49
|
+
(next: BrandPackage) => {
|
|
50
|
+
applyBrandPackage(next, { persist });
|
|
51
|
+
setBrand(next);
|
|
52
|
+
setBrandId(next.id);
|
|
53
|
+
},
|
|
54
|
+
[persist],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const clear = useCallback(() => {
|
|
58
|
+
clearBrand({ persist });
|
|
59
|
+
setBrand(null);
|
|
60
|
+
setBrandId(null);
|
|
61
|
+
}, [persist]);
|
|
62
|
+
|
|
63
|
+
const restore = useCallback(() => {
|
|
64
|
+
const restored = restorePersistedBrand({ persist: false });
|
|
65
|
+
if (restored) {
|
|
66
|
+
setBrand(restored);
|
|
67
|
+
setBrandId(restored.id);
|
|
68
|
+
}
|
|
69
|
+
return restored;
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
return { brand, brandId, apply, clear, restore };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BrandIframePreviewOptions {
|
|
76
|
+
/**
|
|
77
|
+
* Stylesheets the iframe must load before the brand skin
|
|
78
|
+
* (e.g. app CSS URL that already includes tokens + recipe).
|
|
79
|
+
*/
|
|
80
|
+
baseStylesheetHrefs?: string[];
|
|
81
|
+
/** Extra head HTML (fonts CDN, etc.) */
|
|
82
|
+
headHtml?: string;
|
|
83
|
+
/** Minimal body wrapper class */
|
|
84
|
+
bodyClassName?: string;
|
|
85
|
+
/** Called after brand CSS is injected into the iframe */
|
|
86
|
+
onApplied?: (brand: BrandPackage | null) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface UseBrandIframePreviewResult {
|
|
90
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
91
|
+
brand: BrandPackage | null;
|
|
92
|
+
/** Write/update brand inside the iframe document */
|
|
93
|
+
apply: (brand: BrandPackage) => void;
|
|
94
|
+
clear: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* Optional: write a self-contained preview document.
|
|
97
|
+
* Use when the iframe has no host app styles yet.
|
|
98
|
+
*/
|
|
99
|
+
writePreviewDocument: (brand: BrandPackage, bodyHtml?: string) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensureIframeDoc(iframe: HTMLIFrameElement | null): Document | null {
|
|
103
|
+
if (!iframe) return null;
|
|
104
|
+
try {
|
|
105
|
+
return iframe.contentDocument;
|
|
106
|
+
} catch {
|
|
107
|
+
return null; // cross-origin
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function injectBaseStyles(doc: Document, hrefs: string[] | undefined): void {
|
|
112
|
+
if (!hrefs?.length) return;
|
|
113
|
+
for (const href of hrefs) {
|
|
114
|
+
const id = `nebutra-base-${hashHref(href)}`;
|
|
115
|
+
if (doc.getElementById(id)) continue;
|
|
116
|
+
const link = doc.createElement("link");
|
|
117
|
+
link.id = id;
|
|
118
|
+
link.rel = "stylesheet";
|
|
119
|
+
link.href = href;
|
|
120
|
+
doc.head.appendChild(link);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hashHref(href: string): string {
|
|
125
|
+
let h = 0;
|
|
126
|
+
for (let i = 0; i < href.length; i++) h = (h * 31 + href.charCodeAt(i)) | 0;
|
|
127
|
+
return Math.abs(h).toString(36);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Multi-tenant / Create Center iframe preview.
|
|
132
|
+
* Applies Brand Packages into the iframe's document without touching the host shell.
|
|
133
|
+
*/
|
|
134
|
+
export function useBrandIframePreview(
|
|
135
|
+
options: BrandIframePreviewOptions = {},
|
|
136
|
+
): UseBrandIframePreviewResult {
|
|
137
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
138
|
+
const [brand, setBrand] = useState<BrandPackage | null>(null);
|
|
139
|
+
const optsRef = useRef(options);
|
|
140
|
+
optsRef.current = options;
|
|
141
|
+
|
|
142
|
+
const apply = useCallback((next: BrandPackage) => {
|
|
143
|
+
const doc = ensureIframeDoc(iframeRef.current);
|
|
144
|
+
if (!doc?.documentElement) {
|
|
145
|
+
setBrand(next);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
injectBaseStyles(doc, optsRef.current.baseStylesheetHrefs);
|
|
149
|
+
applyBrandPackage(next, { doc, persist: false });
|
|
150
|
+
setBrand(next);
|
|
151
|
+
optsRef.current.onApplied?.(next);
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
const clear = useCallback(() => {
|
|
155
|
+
const doc = ensureIframeDoc(iframeRef.current);
|
|
156
|
+
if (doc) clearBrand({ doc, persist: false });
|
|
157
|
+
setBrand(null);
|
|
158
|
+
optsRef.current.onApplied?.(null);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const writePreviewDocument = useCallback((next: BrandPackage, bodyHtml = "") => {
|
|
162
|
+
const iframe = iframeRef.current;
|
|
163
|
+
if (!iframe) return;
|
|
164
|
+
const css = emitBrandCss(next);
|
|
165
|
+
const links = (optsRef.current.baseStylesheetHrefs ?? [])
|
|
166
|
+
.map((href) => `<link rel="stylesheet" href="${href}" />`)
|
|
167
|
+
.join("\n");
|
|
168
|
+
const html = `<!DOCTYPE html>
|
|
169
|
+
<html lang="en" data-brand="${next.id}" class="${next.darkDefault ? "dark" : ""}">
|
|
170
|
+
<head>
|
|
171
|
+
<meta charset="utf-8" />
|
|
172
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
173
|
+
${links}
|
|
174
|
+
${optsRef.current.headHtml ?? ""}
|
|
175
|
+
<style id="nebutra-brand-skin">${css}</style>
|
|
176
|
+
<style>
|
|
177
|
+
html, body { margin: 0; min-height: 100%; }
|
|
178
|
+
body {
|
|
179
|
+
font-family: var(--font-sans, system-ui, sans-serif);
|
|
180
|
+
background: hsl(var(--background));
|
|
181
|
+
color: hsl(var(--foreground));
|
|
182
|
+
}
|
|
183
|
+
</style>
|
|
184
|
+
</head>
|
|
185
|
+
<body class="${optsRef.current.bodyClassName ?? "zone-product"} bg-background text-foreground">
|
|
186
|
+
${bodyHtml}
|
|
187
|
+
</body>
|
|
188
|
+
</html>`;
|
|
189
|
+
iframe.srcdoc = html;
|
|
190
|
+
setBrand(next);
|
|
191
|
+
optsRef.current.onApplied?.(next);
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
return { iframeRef, brand, apply, clear, writePreviewDocument };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Imperative helper for non-hook call sites */
|
|
198
|
+
export function applyBrandToIframe(
|
|
199
|
+
iframe: HTMLIFrameElement,
|
|
200
|
+
brand: BrandPackage,
|
|
201
|
+
options: ApplyBrandOptions & { baseStylesheetHrefs?: string[] } = {},
|
|
202
|
+
): void {
|
|
203
|
+
const doc = iframe.contentDocument;
|
|
204
|
+
if (!doc) return;
|
|
205
|
+
injectBaseStyles(doc, options.baseStylesheetHrefs);
|
|
206
|
+
applyBrandPackage(brand, { ...options, doc, persist: false });
|
|
207
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { normalizeBrandPackage } from "./normalize";
|
|
2
|
+
import type { BrandPackage, ButtonDefaultStyle } from "./types";
|
|
3
|
+
|
|
4
|
+
const BUTTON_STYLES = new Set<ButtonDefaultStyle>(["solid", "outline", "gradient-stroke"]);
|
|
5
|
+
|
|
6
|
+
export interface ValidationResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
errors: string[];
|
|
9
|
+
warnings: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Validate carrier contract before Create Center publish. */
|
|
13
|
+
export function validateBrandPackage(brand: unknown): ValidationResult {
|
|
14
|
+
const errors: string[] = [];
|
|
15
|
+
const warnings: string[] = [];
|
|
16
|
+
|
|
17
|
+
if (!brand || typeof brand !== "object") {
|
|
18
|
+
return { ok: false, errors: ["Brand package must be an object"], warnings };
|
|
19
|
+
}
|
|
20
|
+
let b: BrandPackage;
|
|
21
|
+
try {
|
|
22
|
+
b = normalizeBrandPackage(brand as BrandPackage);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return { ok: false, errors: [`normalize failed: ${(e as Error).message}`], warnings };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!b.id || typeof b.id !== "string") errors.push("id is required");
|
|
28
|
+
if (!b.name || typeof b.name !== "string") errors.push("name is required");
|
|
29
|
+
if (!b.version || typeof b.version !== "string") errors.push("version is required");
|
|
30
|
+
|
|
31
|
+
if (!b.roles) {
|
|
32
|
+
errors.push("roles missing after normalize");
|
|
33
|
+
} else {
|
|
34
|
+
for (const key of ["canvas", "action", "actionForeground", "border"] as const) {
|
|
35
|
+
if (!b.roles[key]) errors.push(`roles.${key} is required`);
|
|
36
|
+
}
|
|
37
|
+
if (b.roles.brand && b.roles.brand === b.roles.action) {
|
|
38
|
+
warnings.push(
|
|
39
|
+
"roles.brand equals roles.action — brand mark is not separated from CTA (often intentional)",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!b.semantic || typeof b.semantic !== "object") {
|
|
45
|
+
errors.push("semantic is required");
|
|
46
|
+
} else {
|
|
47
|
+
for (const key of [
|
|
48
|
+
"background",
|
|
49
|
+
"foreground",
|
|
50
|
+
"primary",
|
|
51
|
+
"primaryForeground",
|
|
52
|
+
"border",
|
|
53
|
+
"ring",
|
|
54
|
+
] as const) {
|
|
55
|
+
if (!b.semantic[key]) errors.push(`semantic.${key} is required`);
|
|
56
|
+
}
|
|
57
|
+
// Contract: primary must track action
|
|
58
|
+
if (b.roles && b.semantic.primary !== b.roles.action) {
|
|
59
|
+
errors.push("semantic.primary must equal roles.action (CTA bridge)");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!b.recipe || typeof b.recipe !== "object") {
|
|
64
|
+
errors.push("recipe is required");
|
|
65
|
+
} else {
|
|
66
|
+
if (!BUTTON_STYLES.has(b.recipe.buttonDefault as ButtonDefaultStyle)) {
|
|
67
|
+
errors.push(`recipe.buttonDefault must be one of ${[...BUTTON_STYLES].join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
if (!b.recipe.radii?.button) errors.push("recipe.radii.button is required");
|
|
70
|
+
if (!b.recipe.radii?.card) errors.push("recipe.radii.card is required");
|
|
71
|
+
if (!b.recipe.elevationTokens?.card) {
|
|
72
|
+
errors.push("recipe.elevationTokens.card is required (free CSS box-shadow)");
|
|
73
|
+
}
|
|
74
|
+
if (b.recipe.buttonDefault === "gradient-stroke" && !b.recipe.primaryStrokeGradient) {
|
|
75
|
+
warnings.push(
|
|
76
|
+
"gradient-stroke without primaryStrokeGradient — border falls back to solid primary",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!b.typography?.fontSans) errors.push("typography.fontSans is required");
|
|
81
|
+
|
|
82
|
+
if (b.typography?.faces) {
|
|
83
|
+
b.typography.faces.forEach((face, i) => {
|
|
84
|
+
if (!face.family) errors.push(`typography.faces[${i}].family is required`);
|
|
85
|
+
if (!face.src?.length) errors.push(`typography.faces[${i}].src must be non-empty`);
|
|
86
|
+
else {
|
|
87
|
+
for (const [j, src] of face.src.entries()) {
|
|
88
|
+
if (!src.url) errors.push(`typography.faces[${i}].src[${j}].url is required`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (b.zones?.marketing?.display && !b.zones.product) {
|
|
95
|
+
warnings.push(
|
|
96
|
+
"marketing.display set without product zone — app shell may inherit display size",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
b.recipe?.buttonDefault === "solid" &&
|
|
102
|
+
b.semantic?.primary === b.semantic?.primaryForeground
|
|
103
|
+
) {
|
|
104
|
+
warnings.push("primary and primaryForeground are identical — check contrast");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
108
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Related packages:
|
|
22
22
|
* @nebutra/brand → brand primitives (color definitions, motion language)
|
|
23
|
-
* @nebutra/theme →
|
|
23
|
+
* @nebutra/theme → design-language catalog (Brand Package global swap)
|
|
24
24
|
* @nebutra/ui → component library (consumes tokens via CSS variables)
|
|
25
25
|
*/
|
|
26
26
|
|
|
@@ -35,3 +35,33 @@ export const THEME_IDS = ["light", "dark"] as const;
|
|
|
35
35
|
export type ThemeId = (typeof THEME_IDS)[number];
|
|
36
36
|
|
|
37
37
|
export const DEFAULT_THEME: ThemeId = "dark";
|
|
38
|
+
|
|
39
|
+
/** Create Center brand package: carrier contract + runtime apply */
|
|
40
|
+
export {
|
|
41
|
+
applyBrandCss,
|
|
42
|
+
applyBrandPackage,
|
|
43
|
+
applyBrandToIframe,
|
|
44
|
+
BRAND_STORAGE_KEY,
|
|
45
|
+
type BrandColorRoles,
|
|
46
|
+
type BrandElevationTokens,
|
|
47
|
+
type BrandFontFace,
|
|
48
|
+
type BrandPackage,
|
|
49
|
+
type BrandRadii,
|
|
50
|
+
type BrandRecipe,
|
|
51
|
+
type BrandZones,
|
|
52
|
+
type CompileResult,
|
|
53
|
+
clearBrand,
|
|
54
|
+
compileReferoTokens,
|
|
55
|
+
emitBrandCss,
|
|
56
|
+
getActiveBrandId,
|
|
57
|
+
hexToHslChannels,
|
|
58
|
+
inferRecipeFromDesignMd,
|
|
59
|
+
normalizeBrandPackage,
|
|
60
|
+
restorePersistedBrand,
|
|
61
|
+
rolesFromSemantic,
|
|
62
|
+
semanticFromRoles,
|
|
63
|
+
useBrand,
|
|
64
|
+
useBrandIframePreview,
|
|
65
|
+
type ValidationResult,
|
|
66
|
+
validateBrandPackage,
|
|
67
|
+
} from "./brand-package";
|
package/src/theme-provider.tsx
CHANGED
|
@@ -42,8 +42,12 @@ const THEMES = ["light", "dark", "system"] as const satisfies readonly Theme[];
|
|
|
42
42
|
export const THEME_STORAGE_KEY = "theme";
|
|
43
43
|
|
|
44
44
|
interface ThemeContextValue {
|
|
45
|
+
/** Whether a real ThemeProvider is present above the consumer. */
|
|
46
|
+
isProviderBound: boolean;
|
|
45
47
|
/** The raw user preference. May be "system". */
|
|
46
48
|
theme: Theme;
|
|
49
|
+
/** Provider-level override for demos, locked routes, or embedded previews. */
|
|
50
|
+
forcedTheme?: Theme | undefined;
|
|
47
51
|
/** The concretely-applied theme — always "light" or "dark". */
|
|
48
52
|
resolvedTheme: ResolvedTheme;
|
|
49
53
|
/** The OS-level preference (always concrete). */
|
|
@@ -62,7 +66,9 @@ export function useTheme(): ThemeContextValue {
|
|
|
62
66
|
const ctx = useContext(ThemeContext);
|
|
63
67
|
if (ctx) return ctx;
|
|
64
68
|
return {
|
|
69
|
+
isProviderBound: false,
|
|
65
70
|
theme: "system",
|
|
71
|
+
forcedTheme: undefined,
|
|
66
72
|
resolvedTheme: "light",
|
|
67
73
|
systemTheme: "light",
|
|
68
74
|
setTheme: () => {
|
|
@@ -107,6 +113,7 @@ function applyThemeToDom(
|
|
|
107
113
|
resolved: ResolvedTheme,
|
|
108
114
|
attribute: "class" | "data-theme",
|
|
109
115
|
disableTransitionOnChange: boolean,
|
|
116
|
+
nonce?: string,
|
|
110
117
|
) {
|
|
111
118
|
if (typeof document === "undefined") return;
|
|
112
119
|
const root = document.documentElement;
|
|
@@ -114,6 +121,7 @@ function applyThemeToDom(
|
|
|
114
121
|
// Suppress CSS transitions during the swap so the change appears instant.
|
|
115
122
|
if (disableTransitionOnChange) {
|
|
116
123
|
const suppressor = document.createElement("style");
|
|
124
|
+
if (nonce) suppressor.setAttribute("nonce", nonce);
|
|
117
125
|
suppressor.appendChild(
|
|
118
126
|
document.createTextNode(
|
|
119
127
|
"*,*::before,*::after{transition:none!important;animation-duration:0s!important}",
|
|
@@ -134,7 +142,6 @@ function applyThemeToDom(
|
|
|
134
142
|
} else {
|
|
135
143
|
root.setAttribute("data-theme", resolved);
|
|
136
144
|
}
|
|
137
|
-
root.style.colorScheme = resolved;
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
export interface ThemeProviderProps {
|
|
@@ -145,6 +152,8 @@ export interface ThemeProviderProps {
|
|
|
145
152
|
defaultTheme?: Theme;
|
|
146
153
|
/** Whether `"system"` is a valid theme that tracks `prefers-color-scheme`. */
|
|
147
154
|
enableSystem?: boolean;
|
|
155
|
+
/** Lock the rendered theme and expose read-only state to controls. */
|
|
156
|
+
forcedTheme?: Theme | undefined;
|
|
148
157
|
/** Suppress CSS transitions during the swap. Default: `true`. */
|
|
149
158
|
disableTransitionOnChange?: boolean;
|
|
150
159
|
/** Storage key under which the preference is persisted. */
|
|
@@ -158,17 +167,21 @@ export function ThemeProvider({
|
|
|
158
167
|
attribute = "class",
|
|
159
168
|
defaultTheme = "system",
|
|
160
169
|
enableSystem = true,
|
|
170
|
+
forcedTheme,
|
|
161
171
|
disableTransitionOnChange = true,
|
|
162
172
|
storageKey = THEME_STORAGE_KEY,
|
|
173
|
+
nonce,
|
|
163
174
|
}: ThemeProviderProps) {
|
|
164
175
|
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme(storageKey, defaultTheme));
|
|
165
176
|
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => resolveSystemTheme());
|
|
166
177
|
|
|
178
|
+
const effectiveTheme = forcedTheme ?? theme;
|
|
167
179
|
const resolvedTheme: ResolvedTheme =
|
|
168
|
-
|
|
180
|
+
effectiveTheme === "system" ? (enableSystem ? systemTheme : "light") : effectiveTheme;
|
|
169
181
|
|
|
170
182
|
const setTheme = useCallback(
|
|
171
183
|
(next: Theme) => {
|
|
184
|
+
if (forcedTheme !== undefined) return;
|
|
172
185
|
setThemeState(next);
|
|
173
186
|
try {
|
|
174
187
|
if (next === "system") {
|
|
@@ -180,16 +193,16 @@ export function ThemeProvider({
|
|
|
180
193
|
// localStorage may be disabled — accept the loss
|
|
181
194
|
}
|
|
182
195
|
},
|
|
183
|
-
[storageKey],
|
|
196
|
+
[forcedTheme, storageKey],
|
|
184
197
|
);
|
|
185
198
|
|
|
186
199
|
// Apply the resolved theme to the DOM AND sync the cookie so the next
|
|
187
200
|
// SSR can render the correct <html> class directly (no inline script,
|
|
188
201
|
// no React 19 "script in component" warning).
|
|
189
202
|
useEffect(() => {
|
|
190
|
-
applyThemeToDom(resolvedTheme, attribute, disableTransitionOnChange);
|
|
203
|
+
applyThemeToDom(resolvedTheme, attribute, disableTransitionOnChange, nonce);
|
|
191
204
|
writeThemeCookie(resolvedTheme, storageKey);
|
|
192
|
-
}, [resolvedTheme, attribute, disableTransitionOnChange, storageKey]);
|
|
205
|
+
}, [resolvedTheme, attribute, disableTransitionOnChange, nonce, storageKey]);
|
|
193
206
|
|
|
194
207
|
// Follow OS preference changes when in `system` mode
|
|
195
208
|
useEffect(() => {
|
|
@@ -218,13 +231,15 @@ export function ThemeProvider({
|
|
|
218
231
|
|
|
219
232
|
const value = useMemo<ThemeContextValue>(
|
|
220
233
|
() => ({
|
|
234
|
+
isProviderBound: true,
|
|
221
235
|
theme,
|
|
236
|
+
forcedTheme,
|
|
222
237
|
resolvedTheme,
|
|
223
238
|
systemTheme,
|
|
224
239
|
setTheme,
|
|
225
240
|
themes: THEMES,
|
|
226
241
|
}),
|
|
227
|
-
[theme, resolvedTheme, systemTheme, setTheme],
|
|
242
|
+
[theme, forcedTheme, resolvedTheme, systemTheme, setTheme],
|
|
228
243
|
);
|
|
229
244
|
|
|
230
245
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|