@nikala-ui/core 0.0.0-nightly.bb5956a

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 (68) hide show
  1. package/README.md +42 -0
  2. package/package.json +37 -0
  3. package/registry/accordion.json +18 -0
  4. package/registry/alert.json +18 -0
  5. package/registry/avatar.json +17 -0
  6. package/registry/badge.json +18 -0
  7. package/registry/banner.json +19 -0
  8. package/registry/breadcrumb.json +17 -0
  9. package/registry/button.json +18 -0
  10. package/registry/card.json +17 -0
  11. package/registry/checkbox.json +17 -0
  12. package/registry/command.json +25 -0
  13. package/registry/dialog.json +18 -0
  14. package/registry/dropdown-menu.json +18 -0
  15. package/registry/index.json +333 -0
  16. package/registry/input-group.json +21 -0
  17. package/registry/input.json +17 -0
  18. package/registry/kbd.json +18 -0
  19. package/registry/label.json +18 -0
  20. package/registry/list.json +20 -0
  21. package/registry/popover.json +19 -0
  22. package/registry/radio-group.json +18 -0
  23. package/registry/select.json +18 -0
  24. package/registry/separator.json +17 -0
  25. package/registry/sheet.json +19 -0
  26. package/registry/skeleton.json +17 -0
  27. package/registry/switch.json +17 -0
  28. package/registry/tabs.json +17 -0
  29. package/registry/textarea.json +17 -0
  30. package/registry/theme-manager.json +38 -0
  31. package/registry/toast.json +20 -0
  32. package/registry/tooltip.json +18 -0
  33. package/src/index.css +11 -0
  34. package/src/lib/cn.ts +9 -0
  35. package/src/registry/components/ui/accordion.tsx +132 -0
  36. package/src/registry/components/ui/alert.tsx +155 -0
  37. package/src/registry/components/ui/avatar.tsx +114 -0
  38. package/src/registry/components/ui/badge.tsx +50 -0
  39. package/src/registry/components/ui/banner.tsx +189 -0
  40. package/src/registry/components/ui/breadcrumb.tsx +136 -0
  41. package/src/registry/components/ui/button.tsx +63 -0
  42. package/src/registry/components/ui/card.tsx +113 -0
  43. package/src/registry/components/ui/checkbox.tsx +74 -0
  44. package/src/registry/components/ui/command.tsx +286 -0
  45. package/src/registry/components/ui/dialog.tsx +195 -0
  46. package/src/registry/components/ui/dropdown-menu.tsx +265 -0
  47. package/src/registry/components/ui/input-group.tsx +80 -0
  48. package/src/registry/components/ui/input.tsx +28 -0
  49. package/src/registry/components/ui/kbd.tsx +73 -0
  50. package/src/registry/components/ui/label.tsx +30 -0
  51. package/src/registry/components/ui/list.tsx +222 -0
  52. package/src/registry/components/ui/popover.tsx +90 -0
  53. package/src/registry/components/ui/radio-group.tsx +95 -0
  54. package/src/registry/components/ui/select.tsx +153 -0
  55. package/src/registry/components/ui/separator.tsx +37 -0
  56. package/src/registry/components/ui/sheet.tsx +216 -0
  57. package/src/registry/components/ui/skeleton.tsx +24 -0
  58. package/src/registry/components/ui/switch.tsx +68 -0
  59. package/src/registry/components/ui/tabs.tsx +204 -0
  60. package/src/registry/components/ui/textarea.tsx +82 -0
  61. package/src/registry/components/ui/theme-toggle.tsx +200 -0
  62. package/src/registry/components/ui/toast.tsx +124 -0
  63. package/src/registry/components/ui/tooltip.tsx +41 -0
  64. package/src/registry/index.ts +50 -0
  65. package/src/registry/metadata.ts +167 -0
  66. package/src/registry/providers/theme-provider.tsx +208 -0
  67. package/src/registry/providers/theme-script.tsx +58 -0
  68. package/src/registry/providers/theme-transitions.ts +108 -0
@@ -0,0 +1,50 @@
1
+ // src/registry/index.ts
2
+
3
+ /**
4
+ * Represents a single file within a component registry item.
5
+ */
6
+ export interface RegistryFile {
7
+ /** The target relative path where the file will be placed in the user's project (e.g., "ui/button.tsx") */
8
+ path: string;
9
+ /** The full raw text content of the file */
10
+ content: string;
11
+ /** The category type of the file */
12
+ type: "registry:ui" | "registry:util" | "registry:hook";
13
+ }
14
+
15
+ /**
16
+ * Represents the complete manifest structure for a single Nikala UI component.
17
+ */
18
+ export interface RegistryItem {
19
+ /** The unique identifier name of the component (e.g., "button") */
20
+ name: string;
21
+ /** Display title for CLI prompts and documentation */
22
+ title: string;
23
+ /** Short summary describing the component */
24
+ description: string;
25
+ /** Component category type */
26
+ type: "registry:ui";
27
+ /** Required NPM dependencies to be installed automatically (e.g., ["clsx", "tailwind-merge"]) */
28
+ dependencies?: string[];
29
+ /** Internal Nikala UI component dependencies required by this component (e.g., ["button"]) */
30
+ registryDependencies?: string[];
31
+ /** Array of code files comprising this component */
32
+ files: RegistryFile[];
33
+ }
34
+
35
+ /**
36
+ * Lightweight metadata structure used for the central registry index list.
37
+ */
38
+ export interface RegistryIndexItem {
39
+ name: string;
40
+ title: string;
41
+ description: string;
42
+ type: "registry:ui";
43
+ dependencies?: string[];
44
+ registryDependencies?: string[];
45
+ }
46
+
47
+ /**
48
+ * Type alias for the central registry index manifest.
49
+ */
50
+ export type RegistryIndex = RegistryIndexItem[];
@@ -0,0 +1,167 @@
1
+ export interface ComponentMeta {
2
+ title: string;
3
+ description: string;
4
+ dependencies?: string[];
5
+ registryDependencies?: string[];
6
+ }
7
+
8
+ /**
9
+ * Static metadata configuration for all registered Nikala UI components.
10
+ * Extend this record when adding new TSX components.
11
+ */
12
+ export const COMPONENT_METADATA: Record<string, ComponentMeta> = {
13
+ button: {
14
+ title: "Button",
15
+ description: "An interactive button component with variant and size options.",
16
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority"],
17
+ },
18
+ input: {
19
+ title: "Input",
20
+ description: "A standard text input field with styling variants.",
21
+ dependencies: ["clsx", "tailwind-merge"],
22
+ },
23
+ card: {
24
+ title: "Card",
25
+ description: "A versatile container component with header, content, and footer sections.",
26
+ dependencies: ["clsx", "tailwind-merge"],
27
+ },
28
+ badge: {
29
+ title: "Badge",
30
+ description: "A small badge component for status indicators and tags.",
31
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority"],
32
+ },
33
+ avatar: {
34
+ title: "Avatar",
35
+ description: "An image element with fallback representation for representing users.",
36
+ dependencies: ["clsx", "tailwind-merge"],
37
+ },
38
+ separator: {
39
+ title: "Separator",
40
+ description: "Visually or semantically separates content horizontally or vertically.",
41
+ dependencies: ["clsx", "tailwind-merge"],
42
+ },
43
+ textarea: {
44
+ title: "Textarea",
45
+ description: "A multi-line text input field with responsive focus styles.",
46
+ dependencies: ["clsx", "tailwind-merge"],
47
+ },
48
+ label: {
49
+ title: "Label",
50
+ description: "Accessible caption label for form controls and inputs.",
51
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority"],
52
+ },
53
+ skeleton: {
54
+ title: "Skeleton",
55
+ description: "Renders an animated pulse loading placeholder for content loading states.",
56
+ dependencies: ["clsx", "tailwind-merge"],
57
+ },
58
+ switch: {
59
+ title: "Switch",
60
+ description: "A control that allows the user to toggle between checked and unchecked states.",
61
+ dependencies: ["clsx", "tailwind-merge"],
62
+ },
63
+ checkbox: {
64
+ title: "Checkbox",
65
+ description: "A control that allows the user to toggle between checked and unchecked options.",
66
+ dependencies: ["clsx", "tailwind-merge"],
67
+ },
68
+ "radio-group": {
69
+ title: "Radio Group",
70
+ description: "A set of checkable buttons built on Kobalte primitives where only one button can be checked at a time.",
71
+ dependencies: ["clsx", "tailwind-merge", "@kobalte/core"],
72
+ },
73
+ select: {
74
+ title: "Select",
75
+ description: "Displays a list of options for the user to pick from, built on Kobalte primitives.",
76
+ dependencies: ["clsx", "tailwind-merge", "@kobalte/core"],
77
+ },
78
+ tabs: {
79
+ title: "Tabs",
80
+ description: "A set of layered sections of content displayed one at a time.",
81
+ dependencies: ["clsx", "tailwind-merge"],
82
+ },
83
+ accordion: {
84
+ title: "Accordion",
85
+ description: "A vertically stacked set of interactive headings built on Kobalte primitives.",
86
+ dependencies: ["clsx", "tailwind-merge", "@kobalte/core"],
87
+ },
88
+ breadcrumb: {
89
+ title: "Breadcrumb",
90
+ description: "Displays the path to the current resource using a hierarchy of links.",
91
+ dependencies: ["clsx", "tailwind-merge"],
92
+ },
93
+ alert: {
94
+ title: "Alert",
95
+ description: "Displays a callout banner for user feedback with variants, dismiss button, and timer.",
96
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority"],
97
+ },
98
+ dialog: {
99
+ title: "Dialog",
100
+ description: "A modal window overlaying the main content, built on Kobalte primitives with blur and outside-click options.",
101
+ dependencies: ["clsx", "tailwind-merge", "@kobalte/core"],
102
+ },
103
+ sheet: {
104
+ title: "Sheet / Drawer",
105
+ description: "Extends the dialog component to display content that slides in from screen edges.",
106
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority", "@kobalte/core"],
107
+ },
108
+ "dropdown-menu": {
109
+ title: "Dropdown Menu",
110
+ description: "Displays a menu to the user—such as a set of actions or functions—triggered by a button or avatar.",
111
+ dependencies: ["clsx", "tailwind-merge", "@kobalte/core"],
112
+ },
113
+ "theme-manager": {
114
+ title: "Theme Manager",
115
+ description: "Zero-dependency ThemeProvider and ThemeToggle component for switching light, dark, and system themes.",
116
+ dependencies: ["clsx", "tailwind-merge", "@kobalte/core", "lucide-solid"],
117
+ registryDependencies: ["button", "dropdown-menu"],
118
+ },
119
+ banner: {
120
+ title: "Banner",
121
+ description: "An announcement banner with sticky positioning, dismissal persistence, auto-hide timer, Lucide icons, and variant styles.",
122
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority", "lucide-solid"],
123
+ },
124
+ list: {
125
+ title: "List / List Item",
126
+ description: "Compound list components supporting icons, avatars, titles, subtitles, hotkey badges, chevron indicators, and interactive links.",
127
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority", "lucide-solid", "@kobalte/core"],
128
+ },
129
+ kbd: {
130
+ title: "Kbd (Keyboard Key)",
131
+ description: "Keyboard key and shortcut group indicators for displaying hotkeys.",
132
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority"],
133
+ },
134
+ "input-group": {
135
+ title: "Input Group",
136
+ description: "Compound input wrapper for combining text inputs with prefix and suffix addons.",
137
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority"],
138
+ registryDependencies: ["kbd"],
139
+ },
140
+ command: {
141
+ title: "Command / Command Palette",
142
+ description: "Fast, accessible command palette and search modal built on Kobalte Dialog primitives with auto-filtering.",
143
+ dependencies: [
144
+ "clsx",
145
+ "tailwind-merge",
146
+ "class-variance-authority",
147
+ "lucide-solid",
148
+ "@kobalte/core",
149
+ ],
150
+ registryDependencies: ["kbd", "input-group", "list"],
151
+ },
152
+ toast: {
153
+ title: "Toast / Sonner",
154
+ description: "A succinct message displayed temporarily in a toast region, built on Kobalte primitives.",
155
+ dependencies: ["clsx", "tailwind-merge", "class-variance-authority", "lucide-solid", "@kobalte/core"],
156
+ },
157
+ tooltip: {
158
+ title: "Tooltip",
159
+ description: "A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it, built on Kobalte primitives.",
160
+ dependencies: ["clsx", "tailwind-merge", "@kobalte/core"],
161
+ },
162
+ popover: {
163
+ title: "Popover",
164
+ description: "Displays rich content in a portal layer triggered by a button, built on Kobalte primitives.",
165
+ dependencies: ["clsx", "tailwind-merge", "@kobalte/core", "lucide-solid"],
166
+ },
167
+ };
@@ -0,0 +1,208 @@
1
+ import {
2
+ createContext,
3
+ createEffect,
4
+ createSignal,
5
+ onCleanup,
6
+ onMount,
7
+ useContext,
8
+ type ParentComponent,
9
+ type Accessor,
10
+ } from "solid-js";
11
+
12
+ export type Theme = "light" | "dark" | "system";
13
+ export type AccentColor = "wine" | "violet" | "sky" | "emerald" | "rose" | "amber" | "zinc";
14
+ export type Radius = "0" | "0.3" | "0.5" | "0.75" | "1.0";
15
+
16
+ export interface ThemeProviderProps {
17
+ /** Initial default theme mode if no saved preference is found in localStorage */
18
+ defaultTheme?: Theme;
19
+ /** Initial default accent color override if needed */
20
+ defaultAccent?: AccentColor;
21
+ /** Initial default border radius override if needed */
22
+ defaultRadius?: Radius;
23
+ /** Key used to store theme preferences in localStorage */
24
+ storageKey?: string;
25
+ }
26
+
27
+ const ACCENT_COLORS: Record<
28
+ AccentColor,
29
+ { light: string; dark: string; lightFg: string; darkFg: string }
30
+ > = {
31
+ wine: { light: "#722f37", dark: "#9e3b47", lightFg: "#ffffff", darkFg: "#ffffff" },
32
+ violet: { light: "#7c3aed", dark: "#8b5cf6", lightFg: "#ffffff", darkFg: "#ffffff" },
33
+ sky: { light: "#0284c7", dark: "#38bdf8", lightFg: "#ffffff", darkFg: "#0f172a" },
34
+ emerald: { light: "#059669", dark: "#34d399", lightFg: "#ffffff", darkFg: "#052e16" },
35
+ rose: { light: "#e11d48", dark: "#fb7185", lightFg: "#ffffff", darkFg: "#ffffff" },
36
+ amber: { light: "#d97706", dark: "#fbbf24", lightFg: "#ffffff", darkFg: "#111827" },
37
+ zinc: { light: "#18181b", dark: "#fafafa", lightFg: "#fafafa", darkFg: "#18181b" },
38
+ };
39
+
40
+ interface ThemeProviderContextValue {
41
+ theme: Accessor<Theme>;
42
+ setTheme: (theme: Theme) => void;
43
+ accent: Accessor<AccentColor | undefined>;
44
+ setAccent: (accent: AccentColor) => void;
45
+ radius: Accessor<Radius | undefined>;
46
+ setRadius: (radius: Radius) => void;
47
+ }
48
+
49
+ const ThemeProviderContext = createContext<ThemeProviderContextValue>();
50
+
51
+ /**
52
+ * Context provider managing application theme state (light/dark/system), accent colors, and border radius.
53
+ */
54
+ export const ThemeProvider: ParentComponent<ThemeProviderProps> = (props) => {
55
+ const storageKey = props.storageKey || "nikala-theme";
56
+ const defaultTheme = props.defaultTheme || "system";
57
+
58
+ // Safely read saved values from localStorage without forcing unnecessary fallbacks
59
+ const getInitialTheme = (): Theme => {
60
+ if (typeof window === "undefined") return defaultTheme;
61
+ try {
62
+ const saved = localStorage.getItem(`${storageKey}-mode`);
63
+ if (saved === "light" || saved === "dark" || saved === "system") return saved;
64
+ } catch { }
65
+ return defaultTheme;
66
+ };
67
+
68
+ const getInitialAccent = (): AccentColor | undefined => {
69
+ if (typeof window === "undefined") return props.defaultAccent;
70
+ try {
71
+ const saved = localStorage.getItem(`${storageKey}-accent`);
72
+ if (saved && ACCENT_COLORS[saved as AccentColor]) return saved as AccentColor;
73
+ } catch { }
74
+ return props.defaultAccent;
75
+ };
76
+
77
+ const getInitialRadius = (): Radius | undefined => {
78
+ if (typeof window === "undefined") return props.defaultRadius;
79
+ try {
80
+ const saved = localStorage.getItem(`${storageKey}-radius`);
81
+ if (saved) return saved as Radius;
82
+ } catch { }
83
+ return props.defaultRadius;
84
+ };
85
+
86
+ const [theme, setThemeSignal] = createSignal<Theme>(getInitialTheme());
87
+ const [accent, setAccentSignal] = createSignal<AccentColor | undefined>(getInitialAccent());
88
+ const [radius, setRadiusSignal] = createSignal<Radius | undefined>(getInitialRadius());
89
+
90
+ // Applies classes and CSS custom properties when custom overrides are explicitly set
91
+ const applyTheme = (
92
+ targetTheme: Theme,
93
+ currentAccent: AccentColor | undefined,
94
+ currentRadius: Radius | undefined
95
+ ) => {
96
+ if (typeof window === "undefined") return;
97
+
98
+ const root = document.documentElement;
99
+ root.classList.remove("light", "dark");
100
+
101
+ let resolvedDark = false;
102
+ if (targetTheme === "system") {
103
+ resolvedDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
104
+ root.classList.add(resolvedDark ? "dark" : "light");
105
+ } else {
106
+ resolvedDark = targetTheme === "dark";
107
+ root.classList.add(targetTheme);
108
+ }
109
+
110
+ // Override --primary CSS variables ONLY if explicitly chosen
111
+ if (currentAccent && ACCENT_COLORS[currentAccent]) {
112
+ const accentData = ACCENT_COLORS[currentAccent];
113
+ const primaryHex = resolvedDark ? accentData.dark : accentData.light;
114
+ const primaryFgHex = resolvedDark ? accentData.darkFg : accentData.lightFg;
115
+
116
+ root.style.setProperty("--primary", primaryHex);
117
+ root.style.setProperty("--primary-foreground", primaryFgHex);
118
+ }
119
+
120
+ // Override --radius CSS variable ONLY if explicitly chosen
121
+ if (currentRadius) {
122
+ root.style.setProperty("--radius", `${currentRadius}rem`);
123
+ }
124
+ };
125
+
126
+ // Reactively apply theme updates and store preferences in localStorage
127
+ createEffect(() => {
128
+ const t = theme();
129
+ const a = accent();
130
+ const r = radius();
131
+
132
+ applyTheme(t, a, r);
133
+
134
+ if (typeof window !== "undefined") {
135
+ try {
136
+ localStorage.setItem(`${storageKey}-mode`, t);
137
+ if (a) localStorage.setItem(`${storageKey}-accent`, a);
138
+ if (r) localStorage.setItem(`${storageKey}-radius`, r);
139
+ } catch { }
140
+ }
141
+ });
142
+
143
+ onMount(() => {
144
+ if (typeof window === "undefined") return;
145
+
146
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
147
+
148
+ const handleSystemChange = (e: MediaQueryListEvent) => {
149
+ if (theme() === "system") {
150
+ const root = document.documentElement;
151
+ root.classList.remove("light", "dark");
152
+ root.classList.add(e.matches ? "dark" : "light");
153
+
154
+ const currentAccent = accent();
155
+ if (currentAccent && ACCENT_COLORS[currentAccent]) {
156
+ const accentData = ACCENT_COLORS[currentAccent];
157
+ const primaryHex = e.matches ? accentData.dark : accentData.light;
158
+ const primaryFgHex = e.matches ? accentData.darkFg : accentData.lightFg;
159
+
160
+ root.style.setProperty("--primary", primaryHex);
161
+ root.style.setProperty("--primary-foreground", primaryFgHex);
162
+ }
163
+ }
164
+ };
165
+
166
+ if (mediaQuery.addEventListener) {
167
+ mediaQuery.addEventListener("change", handleSystemChange);
168
+ } else if ("addListener" in mediaQuery) {
169
+ (mediaQuery as any).addListener(handleSystemChange);
170
+ }
171
+
172
+ onCleanup(() => {
173
+ if (mediaQuery.removeEventListener) {
174
+ mediaQuery.removeEventListener("change", handleSystemChange);
175
+ } else if ("removeListener" in mediaQuery) {
176
+ (mediaQuery as any).removeListener(handleSystemChange);
177
+ }
178
+ });
179
+ });
180
+
181
+ const value: ThemeProviderContextValue = {
182
+ theme,
183
+ setTheme: (newTheme: Theme) => setThemeSignal(newTheme),
184
+ accent,
185
+ setAccent: (newAccent: AccentColor) => setAccentSignal(newAccent),
186
+ radius,
187
+ setRadius: (newRadius: Radius) => setRadiusSignal(newRadius),
188
+ };
189
+
190
+ return (
191
+ <ThemeProviderContext.Provider value={value}>
192
+ {props.children}
193
+ </ThemeProviderContext.Provider>
194
+ );
195
+ };
196
+
197
+ /**
198
+ * Accesses Nikala UI theme state, accent colors, border radius, and update functions.
199
+ */
200
+ export function useTheme(): ThemeProviderContextValue {
201
+ const context = useContext(ThemeProviderContext);
202
+ if (!context) {
203
+ throw new Error("useTheme must be used within a ThemeProvider");
204
+ }
205
+ return context;
206
+ }
207
+
208
+ export { ThemeScript, type ThemeScriptProps } from "./theme-script";
@@ -0,0 +1,58 @@
1
+ import { type Component } from "solid-js";
2
+
3
+ export interface ThemeScriptProps {
4
+ /** Storage key namespace used in localStorage (default: "nikala-theme") */
5
+ storageKey?: string;
6
+ /** Initial default theme mode if no saved preference exists (default: "system") */
7
+ defaultTheme?: "light" | "dark" | "system";
8
+ /** Initial default primary accent color if no saved preference exists */
9
+ defaultAccent?: string;
10
+ /** Initial default border radius if no saved preference exists */
11
+ defaultRadius?: string;
12
+ }
13
+
14
+ /**
15
+ * Pre-hydration inline script executed synchronously before DOM paint to prevent theme flickering (anti-FOUC).
16
+ */
17
+ export const ThemeScript: Component<ThemeScriptProps> = (props) => {
18
+ const key = props.storageKey || "nikala-theme";
19
+ const defTheme = props.defaultTheme || "system";
20
+ const defAccent = props.defaultAccent || "";
21
+ const defRadius = props.defaultRadius || "";
22
+
23
+ const scriptText = `(function(){try{
24
+ var key = '${key}';
25
+ var mode = localStorage.getItem(key + '-mode') || '${defTheme}';
26
+ var accent = localStorage.getItem(key + '-accent') || '${defAccent}';
27
+ var radius = localStorage.getItem(key + '-radius') || '${defRadius}';
28
+
29
+ var isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
30
+ var resolvedDark = mode === 'dark' || (mode === 'system' && isDark);
31
+
32
+ var root = document.documentElement;
33
+ root.classList.remove('light', 'dark');
34
+ root.classList.add(resolvedDark ? 'dark' : 'light');
35
+ root.style.colorScheme = resolvedDark ? 'dark' : 'light';
36
+
37
+ var colorMap = {
38
+ wine: resolvedDark ? '#9e3b47' : '#722f37',
39
+ violet: resolvedDark ? '#8b5cf6' : '#7c3aed',
40
+ sky: resolvedDark ? '#38bdf8' : '#0284c7',
41
+ emerald: resolvedDark ? '#34d399' : '#059669',
42
+ rose: resolvedDark ? '#fb7185' : '#e11d48',
43
+ amber: resolvedDark ? '#fbbf24' : '#d97706',
44
+ zinc: resolvedDark ? '#fafafa' : '#18181b'
45
+ };
46
+
47
+ if (accent && colorMap[accent]) {
48
+ root.style.setProperty('--primary', colorMap[accent]);
49
+ }
50
+
51
+ if (radius) {
52
+ var radVal = radius.endsWith('rem') ? radius : radius + 'rem';
53
+ root.style.setProperty('--radius', radVal);
54
+ }
55
+ }catch(e){}})();`;
56
+
57
+ return <script innerHTML={scriptText} />;
58
+ };
@@ -0,0 +1,108 @@
1
+ export type ThemeEffect = "none" | "circular" | "fade";
2
+
3
+ /**
4
+ * Injects required CSS view-transition pseudo-element styles to prevent browser mix-blend artifacts.
5
+ */
6
+ function ensureTransitionStyles() {
7
+ if (typeof document === "undefined") return;
8
+ const styleId = "nikala-view-transition-styles";
9
+ if (!document.getElementById(styleId)) {
10
+ const style = document.createElement("style");
11
+ style.id = styleId;
12
+ style.textContent = `
13
+ ::view-transition-old(root),
14
+ ::view-transition-new(root) {
15
+ animation: none;
16
+ mix-blend-mode: normal;
17
+ }
18
+ ::view-transition-old(root) {
19
+ z-index: 1;
20
+ }
21
+ ::view-transition-new(root) {
22
+ z-index: 9999;
23
+ }
24
+ `;
25
+ document.head.appendChild(style);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Executes a theme change with the specified transition effect using the Web View Transitions API.
31
+ *
32
+ * @param effect - Desired transition animation ("none", "circular", "fade")
33
+ * @param event - Mouse or Pointer event to calculate transition center coordinates
34
+ * @param updateThemeCallback - Callback function performing the actual theme state change
35
+ */
36
+ export function runThemeTransition(
37
+ effect: ThemeEffect = "none",
38
+ event: MouseEvent | undefined,
39
+ updateThemeCallback: () => void
40
+ ) {
41
+ // Safe fallback if View Transitions API is unsupported or user prefers reduced motion
42
+ if (
43
+ effect === "none" ||
44
+ typeof document === "undefined" ||
45
+ !(document as any).startViewTransition ||
46
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
47
+ ) {
48
+ updateThemeCallback();
49
+ return;
50
+ }
51
+
52
+ ensureTransitionStyles();
53
+
54
+ // Circular expanding ripple transition originating from click coordinates
55
+ if (effect === "circular" && event) {
56
+ const x = event.clientX;
57
+ const y = event.clientY;
58
+
59
+ const endRadius = Math.hypot(
60
+ Math.max(x, window.innerWidth - x),
61
+ Math.max(y, window.innerHeight - y)
62
+ );
63
+
64
+ const transition = (document as any).startViewTransition(() => {
65
+ updateThemeCallback();
66
+ });
67
+
68
+ transition.ready.then(() => {
69
+ document.documentElement.animate(
70
+ {
71
+ clipPath: [
72
+ `circle(0px at ${x}px ${y}px)`,
73
+ `circle(${endRadius}px at ${x}px ${y}px)`,
74
+ ],
75
+ },
76
+ {
77
+ duration: 500,
78
+ easing: "ease-in-out",
79
+ pseudoElement: "::view-transition-new(root)",
80
+ }
81
+ );
82
+ });
83
+ return;
84
+ }
85
+
86
+ // Smooth opacity fade view transition
87
+ if (effect === "fade") {
88
+ const transition = (document as any).startViewTransition(() => {
89
+ updateThemeCallback();
90
+ });
91
+
92
+ transition.ready.then(() => {
93
+ document.documentElement.animate(
94
+ {
95
+ opacity: [0, 1],
96
+ },
97
+ {
98
+ duration: 350,
99
+ easing: "ease-in-out",
100
+ pseudoElement: "::view-transition-new(root)",
101
+ }
102
+ );
103
+ });
104
+ return;
105
+ }
106
+
107
+ updateThemeCallback();
108
+ }