@nebutra/tokens 0.1.1 → 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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +5 -2
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/AGENTS.md +5 -2
  4. package/CHANGELOG.md +23 -0
  5. package/DESIGN.md +30 -8
  6. package/LICENSE +21 -676
  7. package/README.md +41 -8
  8. package/brands/gsap/brand.json +180 -0
  9. package/brands/linear/brand.json +202 -0
  10. package/brands/notion/brand.json +189 -0
  11. package/brands/raycast/brand.json +167 -0
  12. package/brands/stripe/brand.json +183 -0
  13. package/brands/vanta/brand.json +198 -0
  14. package/brands/vercel/brand.json +207 -0
  15. package/package.json +17 -8
  16. package/recipe.css +114 -0
  17. package/scripts/compile-brand-run.ts +79 -0
  18. package/scripts/compile-brand.mjs +25 -0
  19. package/scripts/emit-skins-run.ts +62 -0
  20. package/scripts/emit-skins.mjs +31 -0
  21. package/scripts/sync-styles.mjs +29 -0
  22. package/skins/README.md +151 -0
  23. package/skins/gsap.css +247 -0
  24. package/skins/linear.css +282 -0
  25. package/skins/notion.css +248 -0
  26. package/skins/raycast.css +272 -0
  27. package/skins/stripe.css +248 -0
  28. package/skins/vanta.css +255 -0
  29. package/skins/vercel.css +283 -0
  30. package/src/brand-package/__tests__/compile-refero.test.ts +427 -0
  31. package/src/brand-package/__tests__/emit-css.test.ts +130 -0
  32. package/src/brand-package/apply-brand.ts +92 -0
  33. package/src/brand-package/compile-helpers.ts +170 -0
  34. package/src/brand-package/compile-refero.ts +69 -0
  35. package/src/brand-package/emit-css.ts +468 -0
  36. package/src/brand-package/hex-to-hsl.ts +100 -0
  37. package/src/brand-package/index.ts +71 -0
  38. package/src/brand-package/infer-recipe.ts +198 -0
  39. package/src/brand-package/normalize.ts +295 -0
  40. package/src/brand-package/presets/context.ts +17 -0
  41. package/src/brand-package/presets/generic.ts +214 -0
  42. package/src/brand-package/presets/gsap.ts +146 -0
  43. package/src/brand-package/presets/index.ts +31 -0
  44. package/src/brand-package/presets/linear.ts +183 -0
  45. package/src/brand-package/presets/notion.ts +208 -0
  46. package/src/brand-package/presets/raycast.ts +138 -0
  47. package/src/brand-package/presets/recipe.ts +61 -0
  48. package/src/brand-package/presets/stripe.ts +207 -0
  49. package/src/brand-package/presets/vanta.ts +220 -0
  50. package/src/brand-package/presets/vercel.ts +187 -0
  51. package/src/brand-package/types.ts +285 -0
  52. package/src/brand-package/use-brand.ts +207 -0
  53. package/src/brand-package/validate.ts +108 -0
  54. package/src/index.ts +31 -1
  55. package/src/theme-provider.tsx +5 -3
  56. package/styles.css +153 -69
  57. 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 → multi-theme presets (6 oklch variants for SaaS product)
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";
@@ -113,6 +113,7 @@ function applyThemeToDom(
113
113
  resolved: ResolvedTheme,
114
114
  attribute: "class" | "data-theme",
115
115
  disableTransitionOnChange: boolean,
116
+ nonce?: string,
116
117
  ) {
117
118
  if (typeof document === "undefined") return;
118
119
  const root = document.documentElement;
@@ -120,6 +121,7 @@ function applyThemeToDom(
120
121
  // Suppress CSS transitions during the swap so the change appears instant.
121
122
  if (disableTransitionOnChange) {
122
123
  const suppressor = document.createElement("style");
124
+ if (nonce) suppressor.setAttribute("nonce", nonce);
123
125
  suppressor.appendChild(
124
126
  document.createTextNode(
125
127
  "*,*::before,*::after{transition:none!important;animation-duration:0s!important}",
@@ -140,7 +142,6 @@ function applyThemeToDom(
140
142
  } else {
141
143
  root.setAttribute("data-theme", resolved);
142
144
  }
143
- root.style.colorScheme = resolved;
144
145
  }
145
146
 
146
147
  export interface ThemeProviderProps {
@@ -169,6 +170,7 @@ export function ThemeProvider({
169
170
  forcedTheme,
170
171
  disableTransitionOnChange = true,
171
172
  storageKey = THEME_STORAGE_KEY,
173
+ nonce,
172
174
  }: ThemeProviderProps) {
173
175
  const [theme, setThemeState] = useState<Theme>(() => readStoredTheme(storageKey, defaultTheme));
174
176
  const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => resolveSystemTheme());
@@ -198,9 +200,9 @@ export function ThemeProvider({
198
200
  // SSR can render the correct <html> class directly (no inline script,
199
201
  // no React 19 "script in component" warning).
200
202
  useEffect(() => {
201
- applyThemeToDom(resolvedTheme, attribute, disableTransitionOnChange);
203
+ applyThemeToDom(resolvedTheme, attribute, disableTransitionOnChange, nonce);
202
204
  writeThemeCookie(resolvedTheme, storageKey);
203
- }, [resolvedTheme, attribute, disableTransitionOnChange, storageKey]);
205
+ }, [resolvedTheme, attribute, disableTransitionOnChange, nonce, storageKey]);
204
206
 
205
207
  // Follow OS preference changes when in `system` mode
206
208
  useEffect(() => {