@nebutra/tokens 0.1.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/AGENTS.md +42 -0
- package/DESIGN.md +333 -0
- package/LICENSE +676 -0
- package/README.md +79 -0
- package/package.json +45 -0
- package/src/index.ts +37 -0
- package/src/theme-provider.tsx +231 -0
- package/styles.css +1391 -0
- package/tsconfig.json +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @nebutra/tokens
|
|
2
|
+
|
|
3
|
+
> Runtime design tokens and theme provider for Nebutra apps. Single source of truth for CSS variables, color scales, and light/dark mode switching.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Internal monorepo dependency
|
|
9
|
+
pnpm add @nebutra/tokens@workspace:*
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
### CSS Tokens (in your app's `globals.css`)
|
|
15
|
+
|
|
16
|
+
```css
|
|
17
|
+
@import "@nebutra/tokens/styles.css";
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This provides:
|
|
21
|
+
- Brand color scales (`--nebutra-blue-*`, `--nebutra-cyan-*`)
|
|
22
|
+
- 12-step functional scales (`--neutral-1..12`, `--blue-1..12`, `--cyan-1..12`)
|
|
23
|
+
- Semantic variables (`--primary`, `--background`, `--border`, etc.)
|
|
24
|
+
- Light/dark mode via `:root` / `.dark`
|
|
25
|
+
- Display-P3 wide gamut with sRGB fallback
|
|
26
|
+
- Tailwind v4 `@theme` integration
|
|
27
|
+
|
|
28
|
+
### Theme Provider (in your root layout)
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { ThemeProvider } from "@nebutra/tokens";
|
|
32
|
+
|
|
33
|
+
export default function RootLayout({ children }) {
|
|
34
|
+
return (
|
|
35
|
+
<ThemeProvider attribute="class" defaultTheme="dark">
|
|
36
|
+
{children}
|
|
37
|
+
</ThemeProvider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Theme Hook
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { useTheme } from "@nebutra/tokens";
|
|
46
|
+
|
|
47
|
+
function ThemeToggle() {
|
|
48
|
+
const { theme, setTheme } = useTheme();
|
|
49
|
+
return (
|
|
50
|
+
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
|
|
51
|
+
Toggle theme
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
| Export | Description |
|
|
60
|
+
|--------|-------------|
|
|
61
|
+
| `ThemeProvider` | React provider for light/dark mode (from next-themes) |
|
|
62
|
+
| `useTheme()` | Hook to read/set current theme |
|
|
63
|
+
| `ThemeProviderProps` | Props type for ThemeProvider |
|
|
64
|
+
| `THEME_IDS` | `["light", "dark"]` |
|
|
65
|
+
| `ThemeId` | `"light" \| "dark"` |
|
|
66
|
+
| `DEFAULT_THEME` | `"dark"` |
|
|
67
|
+
|
|
68
|
+
## Token Architecture
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
@nebutra/brand --> Brand primitives (source data, not runtime)
|
|
72
|
+
@nebutra/tokens --> Runtime CSS variables (THIS PACKAGE)
|
|
73
|
+
@nebutra/theme --> Multi-theme presets (oklch, 6 variants)
|
|
74
|
+
@nebutra/ui --> Components (consume tokens via CSS vars)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Peer Dependencies
|
|
78
|
+
|
|
79
|
+
- `react` ^19
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nebutra/tokens",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Unified theme tokens and theme-provider entry for Nebutra apps",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "AGPL-3.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"./styles.css"
|
|
10
|
+
],
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./styles.css": "./styles.css"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"next-themes": "^0.4.6",
|
|
19
|
+
"@nebutra/design-tokens": "0.1.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "^19"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^19.2.14",
|
|
26
|
+
"typescript": "^5.9.3"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/Nebutra/Nebutra-Sailor/tree/main/packages/design/tokens#readme",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/Nebutra/Nebutra-Sailor.git",
|
|
32
|
+
"directory": "packages/design/tokens"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/Nebutra/Nebutra-Sailor/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node -e \"const fs=require('node:fs'),src='../design-tokens/build/css/styles.generated.css';if(fs.existsSync(src)){fs.copyFileSync(src,'./styles.css');console.log('styles.css refreshed from @nebutra/design-tokens')}else{console.log('styles.css kept (committed copy used; design-tokens build/ absent — likely Turbo cache restore without files)')}\"",
|
|
42
|
+
"sync": "pnpm --filter @nebutra/design-tokens build && node -e \"require('node:fs').copyFileSync('../design-tokens/build/css/styles.generated.css', './styles.css')\"",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nebutra/tokens — Runtime theme tokens & theme switching
|
|
3
|
+
*
|
|
4
|
+
* This package is the SINGLE SOURCE OF TRUTH for runtime design tokens.
|
|
5
|
+
*
|
|
6
|
+
* CSS tokens: @import "@nebutra/tokens/styles.css"
|
|
7
|
+
* → Brand color scales (--nebutra-blue-*, --nebutra-cyan-*)
|
|
8
|
+
* → 12-step functional scales (--neutral-1..12, --blue-1..12, --cyan-1..12)
|
|
9
|
+
* → Semantic variables (--primary, --background, --border, etc.)
|
|
10
|
+
* → Light/dark mode via :root / .dark
|
|
11
|
+
* → Display-P3 wide gamut with sRGB fallback
|
|
12
|
+
* → Tailwind v4 @theme integration
|
|
13
|
+
*
|
|
14
|
+
* JS exports: ThemeProvider, useTheme, THEME_STORAGE_KEY (custom — no next-themes)
|
|
15
|
+
* → App-level light/dark mode switching
|
|
16
|
+
* → ThemeProvider writes BOTH localStorage AND a cookie of the same name.
|
|
17
|
+
* Server Components read the cookie via `next/headers` cookies() and
|
|
18
|
+
* inject the resolved class directly into <html> — zero inline script,
|
|
19
|
+
* zero React 19 "script in component" warning, zero FOUC risk.
|
|
20
|
+
*
|
|
21
|
+
* Related packages:
|
|
22
|
+
* @nebutra/brand → brand primitives (color definitions, motion language)
|
|
23
|
+
* @nebutra/theme → multi-theme presets (6 oklch variants for SaaS product)
|
|
24
|
+
* @nebutra/ui → component library (consumes tokens via CSS variables)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
THEME_STORAGE_KEY,
|
|
29
|
+
ThemeProvider,
|
|
30
|
+
type ThemeProviderProps,
|
|
31
|
+
useTheme,
|
|
32
|
+
} from "./theme-provider";
|
|
33
|
+
|
|
34
|
+
export const THEME_IDS = ["light", "dark"] as const;
|
|
35
|
+
export type ThemeId = (typeof THEME_IDS)[number];
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_THEME: ThemeId = "dark";
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom ThemeProvider — replaces the upstream `next-themes` re-export.
|
|
5
|
+
*
|
|
6
|
+
* Why we own this: `next-themes@0.4.x` renders an inline `<script>` element
|
|
7
|
+
* inside its Client-Component provider for FOUC prevention. React 19 +
|
|
8
|
+
* Next.js 16 (Turbopack) now emit a console error when scripts appear
|
|
9
|
+
* inside Client Components ("Scripts inside React components are never
|
|
10
|
+
* executed when rendering on the client"). The script DID do its job at
|
|
11
|
+
* SSR time, but the warning pollutes the dev console on every page load.
|
|
12
|
+
*
|
|
13
|
+
* Our architecture splits the two concerns cleanly:
|
|
14
|
+
* - {@link ThemeProvider} (Client) — runtime state, OS sync, transitions
|
|
15
|
+
* - {@link ThemeScript} (Server) — synchronous FOUC-prevention inline
|
|
16
|
+
* script rendered into the SSR HTML
|
|
17
|
+
*
|
|
18
|
+
* The API surface mirrors next-themes so the migration is a drop-in for
|
|
19
|
+
* callers (same prop names: `attribute`, `defaultTheme`, `enableSystem`,
|
|
20
|
+
* `disableTransitionOnChange`, `storageKey`, `nonce`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
createContext,
|
|
25
|
+
type ReactNode,
|
|
26
|
+
useCallback,
|
|
27
|
+
useContext,
|
|
28
|
+
useEffect,
|
|
29
|
+
useMemo,
|
|
30
|
+
useState,
|
|
31
|
+
} from "react";
|
|
32
|
+
|
|
33
|
+
type Theme = "light" | "dark" | "system";
|
|
34
|
+
type ResolvedTheme = "light" | "dark";
|
|
35
|
+
|
|
36
|
+
const THEMES = ["light", "dark", "system"] as const satisfies readonly Theme[];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Shared default storage key. Matches what `next-themes` uses, so any user
|
|
40
|
+
* who already has a saved preference keeps it across the migration.
|
|
41
|
+
*/
|
|
42
|
+
export const THEME_STORAGE_KEY = "theme";
|
|
43
|
+
|
|
44
|
+
interface ThemeContextValue {
|
|
45
|
+
/** The raw user preference. May be "system". */
|
|
46
|
+
theme: Theme;
|
|
47
|
+
/** The concretely-applied theme — always "light" or "dark". */
|
|
48
|
+
resolvedTheme: ResolvedTheme;
|
|
49
|
+
/** The OS-level preference (always concrete). */
|
|
50
|
+
systemTheme: ResolvedTheme;
|
|
51
|
+
setTheme: (theme: Theme) => void;
|
|
52
|
+
themes: readonly Theme[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the active theme state. Safe to call outside a provider — returns
|
|
59
|
+
* a sane default in that case so consumers don't need to null-check.
|
|
60
|
+
*/
|
|
61
|
+
export function useTheme(): ThemeContextValue {
|
|
62
|
+
const ctx = useContext(ThemeContext);
|
|
63
|
+
if (ctx) return ctx;
|
|
64
|
+
return {
|
|
65
|
+
theme: "system",
|
|
66
|
+
resolvedTheme: "light",
|
|
67
|
+
systemTheme: "light",
|
|
68
|
+
setTheme: () => {
|
|
69
|
+
// no-op outside provider
|
|
70
|
+
},
|
|
71
|
+
themes: THEMES,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveSystemTheme(): ResolvedTheme {
|
|
76
|
+
if (typeof window === "undefined") return "light";
|
|
77
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readStoredTheme(storageKey: string, fallback: Theme): Theme {
|
|
81
|
+
if (typeof window === "undefined") return fallback;
|
|
82
|
+
try {
|
|
83
|
+
const v = window.localStorage.getItem(storageKey);
|
|
84
|
+
if (v === "light" || v === "dark" || v === "system") return v;
|
|
85
|
+
} catch {
|
|
86
|
+
// localStorage may be disabled (Safari private mode, etc.)
|
|
87
|
+
}
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 1 year — long-lived because theme is a deliberate user choice. */
|
|
92
|
+
const THEME_COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Mirror the resolved theme into a cookie so the next request can be
|
|
96
|
+
* server-rendered with the correct `<html>` class — eliminating the
|
|
97
|
+
* need for an inline FOUC-prevention `<script>` (which React 19 warns
|
|
98
|
+
* about). The cookie value is always concrete ("light" | "dark"), never
|
|
99
|
+
* "system" — Server Components shouldn't try to guess the OS preference.
|
|
100
|
+
*/
|
|
101
|
+
function writeThemeCookie(resolved: ResolvedTheme, cookieName: string) {
|
|
102
|
+
if (typeof document === "undefined") return;
|
|
103
|
+
document.cookie = `${cookieName}=${resolved}; Max-Age=${THEME_COOKIE_MAX_AGE}; Path=/; SameSite=Lax`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function applyThemeToDom(
|
|
107
|
+
resolved: ResolvedTheme,
|
|
108
|
+
attribute: "class" | "data-theme",
|
|
109
|
+
disableTransitionOnChange: boolean,
|
|
110
|
+
) {
|
|
111
|
+
if (typeof document === "undefined") return;
|
|
112
|
+
const root = document.documentElement;
|
|
113
|
+
|
|
114
|
+
// Suppress CSS transitions during the swap so the change appears instant.
|
|
115
|
+
if (disableTransitionOnChange) {
|
|
116
|
+
const suppressor = document.createElement("style");
|
|
117
|
+
suppressor.appendChild(
|
|
118
|
+
document.createTextNode(
|
|
119
|
+
"*,*::before,*::after{transition:none!important;animation-duration:0s!important}",
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
document.head.appendChild(suppressor);
|
|
123
|
+
// Force a reflow so the rule applies before we swap classes
|
|
124
|
+
void window.getComputedStyle(suppressor).opacity;
|
|
125
|
+
// Remove the rule on the next tick; transitions resume afterwards
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
if (suppressor.parentNode) suppressor.parentNode.removeChild(suppressor);
|
|
128
|
+
}, 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (attribute === "class") {
|
|
132
|
+
root.classList.remove("light", "dark");
|
|
133
|
+
root.classList.add(resolved);
|
|
134
|
+
} else {
|
|
135
|
+
root.setAttribute("data-theme", resolved);
|
|
136
|
+
}
|
|
137
|
+
root.style.colorScheme = resolved;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ThemeProviderProps {
|
|
141
|
+
children: ReactNode;
|
|
142
|
+
/** Which DOM attribute to set on `<html>`. Default: `"class"`. */
|
|
143
|
+
attribute?: "class" | "data-theme";
|
|
144
|
+
/** Default theme when no preference is stored. Default: `"system"`. */
|
|
145
|
+
defaultTheme?: Theme;
|
|
146
|
+
/** Whether `"system"` is a valid theme that tracks `prefers-color-scheme`. */
|
|
147
|
+
enableSystem?: boolean;
|
|
148
|
+
/** Suppress CSS transitions during the swap. Default: `true`. */
|
|
149
|
+
disableTransitionOnChange?: boolean;
|
|
150
|
+
/** Storage key under which the preference is persisted. */
|
|
151
|
+
storageKey?: string;
|
|
152
|
+
/** CSP nonce — accepted for API compatibility, currently unused by client logic. */
|
|
153
|
+
nonce?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function ThemeProvider({
|
|
157
|
+
children,
|
|
158
|
+
attribute = "class",
|
|
159
|
+
defaultTheme = "system",
|
|
160
|
+
enableSystem = true,
|
|
161
|
+
disableTransitionOnChange = true,
|
|
162
|
+
storageKey = THEME_STORAGE_KEY,
|
|
163
|
+
}: ThemeProviderProps) {
|
|
164
|
+
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme(storageKey, defaultTheme));
|
|
165
|
+
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => resolveSystemTheme());
|
|
166
|
+
|
|
167
|
+
const resolvedTheme: ResolvedTheme =
|
|
168
|
+
theme === "system" ? (enableSystem ? systemTheme : "light") : theme;
|
|
169
|
+
|
|
170
|
+
const setTheme = useCallback(
|
|
171
|
+
(next: Theme) => {
|
|
172
|
+
setThemeState(next);
|
|
173
|
+
try {
|
|
174
|
+
if (next === "system") {
|
|
175
|
+
window.localStorage.removeItem(storageKey);
|
|
176
|
+
} else {
|
|
177
|
+
window.localStorage.setItem(storageKey, next);
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// localStorage may be disabled — accept the loss
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[storageKey],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Apply the resolved theme to the DOM AND sync the cookie so the next
|
|
187
|
+
// SSR can render the correct <html> class directly (no inline script,
|
|
188
|
+
// no React 19 "script in component" warning).
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
applyThemeToDom(resolvedTheme, attribute, disableTransitionOnChange);
|
|
191
|
+
writeThemeCookie(resolvedTheme, storageKey);
|
|
192
|
+
}, [resolvedTheme, attribute, disableTransitionOnChange, storageKey]);
|
|
193
|
+
|
|
194
|
+
// Follow OS preference changes when in `system` mode
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!enableSystem) return;
|
|
197
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
198
|
+
const handler = (event: MediaQueryListEvent) =>
|
|
199
|
+
setSystemTheme(event.matches ? "dark" : "light");
|
|
200
|
+
mq.addEventListener("change", handler);
|
|
201
|
+
return () => mq.removeEventListener("change", handler);
|
|
202
|
+
}, [enableSystem]);
|
|
203
|
+
|
|
204
|
+
// Sync across tabs / windows
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const handler = (event: StorageEvent) => {
|
|
207
|
+
if (event.key !== storageKey) return;
|
|
208
|
+
const next = event.newValue;
|
|
209
|
+
if (next === "light" || next === "dark" || next === "system") {
|
|
210
|
+
setThemeState(next);
|
|
211
|
+
} else if (next === null) {
|
|
212
|
+
setThemeState(defaultTheme);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
window.addEventListener("storage", handler);
|
|
216
|
+
return () => window.removeEventListener("storage", handler);
|
|
217
|
+
}, [storageKey, defaultTheme]);
|
|
218
|
+
|
|
219
|
+
const value = useMemo<ThemeContextValue>(
|
|
220
|
+
() => ({
|
|
221
|
+
theme,
|
|
222
|
+
resolvedTheme,
|
|
223
|
+
systemTheme,
|
|
224
|
+
setTheme,
|
|
225
|
+
themes: THEMES,
|
|
226
|
+
}),
|
|
227
|
+
[theme, resolvedTheme, systemTheme, setTheme],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
231
|
+
}
|