@smicolon/fasla-ui 0.3.0 → 0.4.1

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 (35) hide show
  1. package/README.md +52 -20
  2. package/dist/index.cjs +319 -0
  3. package/dist/index.d.cts +5 -0
  4. package/dist/index.js +1 -1
  5. package/dist/lib/utils.cjs +11 -0
  6. package/dist/lib/utils.d.cts +9 -0
  7. package/dist/motion/index.cjs +61 -0
  8. package/dist/motion/index.d.cts +33 -0
  9. package/dist/registry/index.cjs +179 -0
  10. package/dist/registry/index.d.cts +17 -0
  11. package/dist/registry/index.js +1 -1
  12. package/dist/tokens/index.cjs +77 -0
  13. package/dist/tokens/index.d.cts +124 -0
  14. package/package.json +14 -7
  15. package/registry/blocks/empty-state/EmptyState.test.tsx +11 -0
  16. package/registry/blocks/empty-state/EmptyState.tsx +10 -2
  17. package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
  18. package/registry/blocks/sidebar/Sidebar.tsx +28 -11
  19. package/registry/blocks/stats-card/StatsCard.tsx +3 -2
  20. package/registry/effects/animated-gradient/animated-gradient.tsx +3 -7
  21. package/registry/effects/effects.test.tsx +61 -0
  22. package/registry/effects/glow-card/glow-card.tsx +18 -12
  23. package/registry/effects/text-reveal/text-reveal.tsx +3 -1
  24. package/registry/effects/typewriter-text/typewriter-text.tsx +49 -44
  25. package/registry/next14-tsconfig.test.ts +43 -0
  26. package/registry/ui/checkbox/checkbox.test.tsx +23 -0
  27. package/registry/ui/checkbox/checkbox.tsx +3 -1
  28. package/registry/ui/combobox/combobox.test.tsx +30 -1
  29. package/registry/ui/combobox/combobox.tsx +7 -4
  30. package/registry/ui/input/input.test.tsx +1 -1
  31. package/registry/ui/input/input.tsx +1 -1
  32. package/registry/ui/skeleton/index.ts +1 -0
  33. package/registry/ui/skeleton/skeleton.test.tsx +113 -29
  34. package/registry/ui/skeleton/skeleton.tsx +101 -41
  35. package/registry.json +1 -1
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ // src/registry/index.ts
4
+ var registry = [
5
+ // UI Primitives
6
+ {
7
+ name: "button",
8
+ type: "ui",
9
+ title: "Button",
10
+ description: "A button component with multiple variants, sizes, and loading state.",
11
+ sourcePath: "registry/ui/button",
12
+ dependencies: ["@radix-ui/react-slot", "class-variance-authority", "lucide-react"],
13
+ categories: ["primitives", "forms"]
14
+ },
15
+ {
16
+ name: "input",
17
+ type: "ui",
18
+ title: "Input",
19
+ description: "An input component with variants for validation states and icon support.",
20
+ sourcePath: "registry/ui/input",
21
+ dependencies: ["class-variance-authority"],
22
+ categories: ["primitives", "forms"]
23
+ },
24
+ {
25
+ name: "card",
26
+ type: "ui",
27
+ title: "Card",
28
+ description: "A card component with header, content, and footer sections.",
29
+ sourcePath: "registry/ui/card",
30
+ categories: ["primitives", "layout"]
31
+ },
32
+ {
33
+ name: "badge",
34
+ type: "ui",
35
+ title: "Badge",
36
+ description: "A compact inline label for status, category or metadata: solid, soft and outline types in six semantic tones, with an optional icon, avatar and close button.",
37
+ sourcePath: "registry/ui/badge",
38
+ dependencies: ["class-variance-authority", "lucide-react"],
39
+ categories: ["primitives", "data-display"]
40
+ },
41
+ {
42
+ name: "avatar",
43
+ type: "ui",
44
+ title: "Avatar",
45
+ description: "A user's photo, initials or icon, in three sizes and two radii, with an optional border and presence dot. Falls back from photo to initials to icon on its own.",
46
+ sourcePath: "registry/ui/avatar",
47
+ dependencies: ["class-variance-authority", "lucide-react"],
48
+ registryDependencies: ["status-indicator"],
49
+ categories: ["primitives", "data-display"]
50
+ },
51
+ {
52
+ name: "status-indicator",
53
+ type: "ui",
54
+ title: "Status Indicator",
55
+ description: "A presence dot \u2014 online, away, busy or offline \u2014 with an accessible name in the page's language, so colour never carries the status alone.",
56
+ sourcePath: "registry/ui/status-indicator",
57
+ dependencies: ["class-variance-authority"],
58
+ categories: ["primitives", "data-display"]
59
+ },
60
+ {
61
+ name: "skeleton",
62
+ type: "ui",
63
+ title: "Skeleton",
64
+ description: "Loading placeholders that replace shadcn's Skeleton: a plain block plus text, avatar, list item and card shapes that stay still under reduced motion.",
65
+ sourcePath: "registry/ui/skeleton",
66
+ categories: ["primitives", "feedback"]
67
+ },
68
+ {
69
+ name: "radio",
70
+ type: "ui",
71
+ title: "Radio",
72
+ description: "An accessible radio for choosing one option from a set, with a plain and a bordered layout variant.",
73
+ sourcePath: "registry/ui/radio",
74
+ dependencies: ["class-variance-authority"],
75
+ categories: ["primitives", "forms"]
76
+ },
77
+ // Blocks
78
+ {
79
+ name: "app-shell",
80
+ type: "block",
81
+ title: "App Shell",
82
+ description: "Application shell with sidebar, header, and content area.",
83
+ sourcePath: "registry/blocks/app-shell",
84
+ categories: ["layout", "application"]
85
+ },
86
+ {
87
+ name: "page-header",
88
+ type: "block",
89
+ title: "Page Header",
90
+ description: "Page header with title, breadcrumb, description, and actions.",
91
+ sourcePath: "registry/blocks/page-header",
92
+ categories: ["layout", "navigation"]
93
+ },
94
+ {
95
+ name: "empty-state",
96
+ type: "block",
97
+ title: "Empty State",
98
+ description: "Empty state component with icon, title, description, and actions.",
99
+ sourcePath: "registry/blocks/empty-state",
100
+ dependencies: ["lucide-react"],
101
+ categories: ["feedback", "data-display"]
102
+ },
103
+ {
104
+ name: "form-section",
105
+ type: "block",
106
+ title: "Form Section",
107
+ description: "Form section with title, description, fields, and actions.",
108
+ sourcePath: "registry/blocks/form-section",
109
+ categories: ["forms", "layout"]
110
+ },
111
+ {
112
+ name: "data-table",
113
+ type: "block",
114
+ title: "Data Table",
115
+ description: "Data table with sorting, pagination, and custom cell rendering.",
116
+ sourcePath: "registry/blocks/data-table",
117
+ categories: ["data-display", "tables"]
118
+ },
119
+ // Effects
120
+ {
121
+ name: "shimmer-button",
122
+ type: "effect",
123
+ title: "Shimmer Button",
124
+ description: "A button with animated shimmer effect.",
125
+ sourcePath: "registry/effects/shimmer-button",
126
+ dependencies: ["framer-motion"],
127
+ categories: ["effects", "buttons"]
128
+ },
129
+ {
130
+ name: "animated-gradient",
131
+ type: "effect",
132
+ title: "Animated Gradient",
133
+ description: "Animated gradient background and text effects.",
134
+ sourcePath: "registry/effects/animated-gradient",
135
+ dependencies: ["framer-motion"],
136
+ categories: ["effects", "backgrounds"]
137
+ },
138
+ {
139
+ name: "text-reveal",
140
+ type: "effect",
141
+ title: "Text Reveal",
142
+ description: "Character and word reveal animations.",
143
+ sourcePath: "registry/effects/text-reveal",
144
+ dependencies: ["framer-motion"],
145
+ categories: ["effects", "text"]
146
+ },
147
+ {
148
+ name: "border-beam",
149
+ type: "effect",
150
+ title: "Border Beam",
151
+ description: "Animated border beam and glowing border effects.",
152
+ sourcePath: "registry/effects/border-beam",
153
+ dependencies: ["framer-motion"],
154
+ categories: ["effects", "borders"]
155
+ },
156
+ {
157
+ name: "spotlight",
158
+ type: "effect",
159
+ title: "Spotlight",
160
+ description: "Cursor-following spotlight effect.",
161
+ sourcePath: "registry/effects/spotlight",
162
+ dependencies: ["framer-motion"],
163
+ categories: ["effects", "interactive"]
164
+ }
165
+ ];
166
+ function getRegistryItem(name) {
167
+ return registry.find((item) => item.name === name);
168
+ }
169
+ function getRegistryItemsByType(type) {
170
+ return registry.filter((item) => item.type === type);
171
+ }
172
+ function getRegistryItemsByCategory(category) {
173
+ return registry.filter((item) => item.categories?.includes(category));
174
+ }
175
+
176
+ exports.getRegistryItem = getRegistryItem;
177
+ exports.getRegistryItemsByCategory = getRegistryItemsByCategory;
178
+ exports.getRegistryItemsByType = getRegistryItemsByType;
179
+ exports.registry = registry;
@@ -0,0 +1,17 @@
1
+ type RegistryItemType = "ui" | "block" | "effect" | "hook";
2
+ type RegistryItem = {
3
+ name: string;
4
+ type: RegistryItemType;
5
+ title: string;
6
+ description: string;
7
+ sourcePath: string;
8
+ dependencies?: string[];
9
+ registryDependencies?: string[];
10
+ categories?: string[];
11
+ };
12
+ declare const registry: RegistryItem[];
13
+ declare function getRegistryItem(name: string): RegistryItem | undefined;
14
+ declare function getRegistryItemsByType(type: RegistryItemType): RegistryItem[];
15
+ declare function getRegistryItemsByCategory(category: string): RegistryItem[];
16
+
17
+ export { type RegistryItem, type RegistryItemType, getRegistryItem, getRegistryItemsByCategory, getRegistryItemsByType, registry };
@@ -59,7 +59,7 @@ var registry = [
59
59
  name: "skeleton",
60
60
  type: "ui",
61
61
  title: "Skeleton",
62
- description: "Loading skeleton placeholders with pre-built variants for text, avatars, and cards.",
62
+ description: "Loading placeholders that replace shadcn's Skeleton: a plain block plus text, avatar, list item and card shapes that stay still under reduced motion.",
63
63
  sourcePath: "registry/ui/skeleton",
64
64
  categories: ["primitives", "feedback"]
65
65
  },
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // src/tokens/model.ts
4
+ var baseTokens = {
5
+ radius: { sm: "0.5rem", md: "0.75rem", lg: "1rem" },
6
+ spacing: { densityMultiplier: { compact: 0.9, comfortable: 1, spacious: 1.1 } },
7
+ // Brand V2.5 §14 — Geist is the LTR face. Arabic is set in Cairo, which the
8
+ // Tailwind preset selects per direction; this scaffold carries only the base.
9
+ // 16/24 is the `Base` rung, identical in both Figma ramps.
10
+ typography: { fontFamily: "Geist", baseFontSizePx: 16, lineHeight: 1.5 },
11
+ motion: { durationsMs: { fast: 120, base: 180, slow: 260 } }
12
+ };
13
+
14
+ // src/tokens/tailwind.ts
15
+ var semanticColor = (name) => `color-mix(in oklch, var(--${name}) calc(<alpha-value> * 100%), transparent)`;
16
+ var c = semanticColor;
17
+ var tailwindSemanticColors = {
18
+ // Surfaces
19
+ background: c("background"),
20
+ foreground: c("foreground"),
21
+ card: { DEFAULT: c("card"), foreground: c("card-foreground") },
22
+ popover: { DEFAULT: c("popover"), foreground: c("popover-foreground") },
23
+ // Actions. `primary` is Figma's strong neutral (#0a0a0a light, #fafafa dark),
24
+ // identical to `foreground` in both modes. It is NOT a brand accent — an app
25
+ // that wants its brand colour should define its own token for it.
26
+ primary: { DEFAULT: c("primary"), foreground: c("primary-foreground") },
27
+ secondary: { DEFAULT: c("secondary"), foreground: c("secondary-foreground") },
28
+ muted: { DEFAULT: c("muted"), foreground: c("muted-foreground") },
29
+ accent: { DEFAULT: c("accent"), foreground: c("accent-foreground") },
30
+ destructive: {
31
+ DEFAULT: c("destructive"),
32
+ foreground: c("destructive-foreground")
33
+ },
34
+ // Lines and focus
35
+ border: c("border"),
36
+ input: c("input"),
37
+ ring: c("ring"),
38
+ // Status
39
+ success: { DEFAULT: c("success"), foreground: c("success-foreground") },
40
+ warning: { DEFAULT: c("warning"), foreground: c("warning-foreground") },
41
+ info: { DEFAULT: c("info"), foreground: c("info-foreground") },
42
+ // Soft tints — Figma's `opacity/*` variables, already translucent. The Badge
43
+ // Soft type lays one over `card`, exactly as Figma stacks the two fills, so
44
+ // the chip reads the same on any surface. Only `primary` changes in Dark.
45
+ soft: {
46
+ primary: c("soft-primary"),
47
+ secondary: c("soft-secondary"),
48
+ info: c("soft-info"),
49
+ success: c("soft-success"),
50
+ warning: c("soft-warning"),
51
+ destructive: c("soft-destructive")
52
+ },
53
+ // Inverse — a fixed contrast band, not a mode-reactive surface.
54
+ "background-inverse": c("background-inverse"),
55
+ "foreground-inverse": c("foreground-inverse"),
56
+ "muted-foreground-inverse": c("muted-foreground-inverse"),
57
+ // Charts
58
+ "chart-1": c("chart-1"),
59
+ "chart-2": c("chart-2"),
60
+ "chart-3": c("chart-3"),
61
+ "chart-4": c("chart-4"),
62
+ "chart-5": c("chart-5"),
63
+ sidebar: {
64
+ DEFAULT: c("sidebar"),
65
+ foreground: c("sidebar-foreground"),
66
+ primary: c("sidebar-primary"),
67
+ "primary-foreground": c("sidebar-primary-foreground"),
68
+ accent: c("sidebar-accent"),
69
+ "accent-foreground": c("sidebar-accent-foreground"),
70
+ border: c("sidebar-border"),
71
+ ring: c("sidebar-ring")
72
+ }
73
+ };
74
+
75
+ exports.baseTokens = baseTokens;
76
+ exports.semanticColor = semanticColor;
77
+ exports.tailwindSemanticColors = tailwindSemanticColors;
@@ -0,0 +1,124 @@
1
+ type Density = "compact" | "comfortable" | "spacious";
2
+ interface TokenSet {
3
+ radius: {
4
+ sm: string;
5
+ md: string;
6
+ lg: string;
7
+ };
8
+ spacing: {
9
+ /** Multipliers for vertical rhythm based on density */
10
+ densityMultiplier: Record<Density, number>;
11
+ };
12
+ typography: {
13
+ fontFamily: string;
14
+ baseFontSizePx: number;
15
+ lineHeight: number;
16
+ };
17
+ motion: {
18
+ durationsMs: {
19
+ fast: number;
20
+ base: number;
21
+ slow: number;
22
+ };
23
+ };
24
+ }
25
+ declare const baseTokens: TokenSet;
26
+
27
+ /**
28
+ * Tailwind mapping for the Fasla semantic colour tokens.
29
+ *
30
+ * These are the 41 `theme/*` variables of the Figma "☾ Mode" collection, plus
31
+ * the six `opacity/*` Soft tints. Figma
32
+ * is the source of truth; `design/tokens/mode.json` holds the committed
33
+ * snapshot, and both apps declare the same values in their own `globals.css`.
34
+ *
35
+ * Spread this into `theme.extend.colors` and define the matching CSS variables
36
+ * as full colours (hex or oklch) on `:root` and `.dark`.
37
+ */
38
+ /**
39
+ * Resolve a semantic token to a colour Tailwind can apply an opacity modifier to.
40
+ *
41
+ * A bare `var(--x)` does NOT work: in Tailwind 3 the `/opacity` modifier is
42
+ * silently dropped when the colour is an unparseable string, so `bg-primary/10`
43
+ * compiles to no rule at all. Wrapping in `color-mix` with the `<alpha-value>`
44
+ * placeholder keeps both `bg-primary` and `bg-primary/10` working, while letting
45
+ * the CSS variables stay full colours rather than bare channel triplets.
46
+ *
47
+ * Needs Chrome 111+, Safari 16.2+, Firefox 113+.
48
+ */
49
+ declare const semanticColor: (name: string) => string;
50
+ declare const tailwindSemanticColors: {
51
+ background: string;
52
+ foreground: string;
53
+ card: {
54
+ DEFAULT: string;
55
+ foreground: string;
56
+ };
57
+ popover: {
58
+ DEFAULT: string;
59
+ foreground: string;
60
+ };
61
+ primary: {
62
+ DEFAULT: string;
63
+ foreground: string;
64
+ };
65
+ secondary: {
66
+ DEFAULT: string;
67
+ foreground: string;
68
+ };
69
+ muted: {
70
+ DEFAULT: string;
71
+ foreground: string;
72
+ };
73
+ accent: {
74
+ DEFAULT: string;
75
+ foreground: string;
76
+ };
77
+ destructive: {
78
+ DEFAULT: string;
79
+ foreground: string;
80
+ };
81
+ border: string;
82
+ input: string;
83
+ ring: string;
84
+ success: {
85
+ DEFAULT: string;
86
+ foreground: string;
87
+ };
88
+ warning: {
89
+ DEFAULT: string;
90
+ foreground: string;
91
+ };
92
+ info: {
93
+ DEFAULT: string;
94
+ foreground: string;
95
+ };
96
+ soft: {
97
+ primary: string;
98
+ secondary: string;
99
+ info: string;
100
+ success: string;
101
+ warning: string;
102
+ destructive: string;
103
+ };
104
+ "background-inverse": string;
105
+ "foreground-inverse": string;
106
+ "muted-foreground-inverse": string;
107
+ "chart-1": string;
108
+ "chart-2": string;
109
+ "chart-3": string;
110
+ "chart-4": string;
111
+ "chart-5": string;
112
+ sidebar: {
113
+ DEFAULT: string;
114
+ foreground: string;
115
+ primary: string;
116
+ "primary-foreground": string;
117
+ accent: string;
118
+ "accent-foreground": string;
119
+ border: string;
120
+ ring: string;
121
+ };
122
+ };
123
+
124
+ export { type Density, type TokenSet, baseTokens, semanticColor, tailwindSemanticColors };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smicolon/fasla-ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Professional UI components with animations and app building blocks",
5
5
  "license": "MIT",
6
6
  "author": "Smicolon",
@@ -27,8 +27,15 @@
27
27
  "import": "./dist/index.js"
28
28
  },
29
29
  "./tokens": {
30
- "types": "./dist/tokens/index.d.ts",
31
- "import": "./dist/tokens/index.js"
30
+ "import": {
31
+ "types": "./dist/tokens/index.d.ts",
32
+ "default": "./dist/tokens/index.js"
33
+ },
34
+ "require": {
35
+ "types": "./dist/tokens/index.d.cts",
36
+ "default": "./dist/tokens/index.cjs"
37
+ },
38
+ "default": "./dist/tokens/index.js"
32
39
  },
33
40
  "./motion": {
34
41
  "types": "./dist/motion/index.d.ts",
@@ -84,10 +91,10 @@
84
91
  "vitest": "^1.6.0"
85
92
  },
86
93
  "peerDependencies": {
87
- "react": "^18.0.0",
88
- "react-dom": "^18.0.0",
89
- "framer-motion": "^11.0.0",
90
- "tailwindcss": "^3.4.0"
94
+ "react": "^18.0.0 || ^19.0.0",
95
+ "react-dom": "^18.0.0 || ^19.0.0",
96
+ "framer-motion": "^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0",
97
+ "tailwindcss": "^3.4.0 || ^4.1.4"
91
98
  },
92
99
  "peerDependenciesMeta": {
93
100
  "framer-motion": {
@@ -92,6 +92,17 @@ describe("EmptySearchResults", () => {
92
92
  const svg = container.querySelector("svg")
93
93
  expect(svg).toBeInTheDocument()
94
94
  })
95
+
96
+ it("accepts the deprecated onClear without passing it to the DOM or showing a control", () => {
97
+ const error = vi.spyOn(console, "error").mockImplementation(() => {})
98
+ try {
99
+ render(<EmptySearchResults onClear={() => {}} />)
100
+ expect(error).not.toHaveBeenCalled()
101
+ expect(screen.queryByRole("button")).not.toBeInTheDocument()
102
+ } finally {
103
+ error.mockRestore()
104
+ }
105
+ })
95
106
  })
96
107
 
97
108
  describe("EmptyData", () => {
@@ -99,18 +99,26 @@ export function EmptyState({
99
99
  /** Pre-built empty state for no search results */
100
100
  export function EmptySearchResults({
101
101
  query,
102
- onClear,
103
102
  title,
104
103
  description,
105
- ...props
104
+ ...rest
106
105
  }: Omit<EmptyStateProps, "title" | "description"> & {
107
106
  /** Replaces the English title */
108
107
  title?: string
109
108
  /** Replaces the English description */
110
109
  description?: string
111
110
  query?: string
111
+ /**
112
+ * @deprecated Does nothing: this empty state has no clear control. Pass a
113
+ * button as `action` instead.
114
+ */
112
115
  onClear?: () => void
113
116
  }) {
117
+ // onClear is kept so existing callers still compile; take it out here so it
118
+ // never reaches the DOM through EmptyState's spread.
119
+ const props = { ...rest }
120
+ delete props.onClear
121
+
114
122
  return (
115
123
  <EmptyState
116
124
  // ?? rather than a later spread, so an undefined title keeps the English one
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { render, screen, fireEvent } from "@testing-library/react"
3
+ import { Sidebar, SidebarCollapseButton } from "./Sidebar"
4
+
5
+ describe("Sidebar onCollapsedChange", () => {
6
+ it("is called when the collapse button inside it is pressed", () => {
7
+ const onSidebarChange = vi.fn()
8
+ const onButtonChange = vi.fn()
9
+ render(
10
+ <Sidebar onCollapsedChange={onSidebarChange}>
11
+ <SidebarCollapseButton collapsed={false} onCollapsedChange={onButtonChange} />
12
+ </Sidebar>
13
+ )
14
+ fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }))
15
+ expect(onButtonChange).toHaveBeenCalledWith(true)
16
+ expect(onSidebarChange).toHaveBeenCalledWith(true)
17
+ })
18
+
19
+ it("calls a handler given to both the sidebar and the button once", () => {
20
+ const onChange = vi.fn()
21
+ render(
22
+ <Sidebar collapsed onCollapsedChange={onChange}>
23
+ <SidebarCollapseButton collapsed onCollapsedChange={onChange} />
24
+ </Sidebar>
25
+ )
26
+ fireEvent.click(screen.getByRole("button", { name: "Expand sidebar" }))
27
+ expect(onChange).toHaveBeenCalledTimes(1)
28
+ expect(onChange).toHaveBeenCalledWith(false)
29
+ })
30
+
31
+ it("is not passed to the aside", () => {
32
+ const { container } = render(
33
+ <Sidebar onCollapsedChange={() => {}}>
34
+ <span />
35
+ </Sidebar>
36
+ )
37
+ expect(container.querySelector("aside")?.getAttributeNames()).toEqual(["class"])
38
+ })
39
+ })
@@ -20,6 +20,11 @@ const widthClasses = {
20
20
  lg: "w-72",
21
21
  }
22
22
 
23
+ /** The enclosing Sidebar's onCollapsedChange, for SidebarCollapseButton. */
24
+ const SidebarCollapsedChangeContext = React.createContext<
25
+ ((collapsed: boolean) => void) | undefined
26
+ >(undefined)
27
+
23
28
  export function Sidebar({
24
29
  collapsed = false,
25
30
  onCollapsedChange,
@@ -29,16 +34,18 @@ export function Sidebar({
29
34
  ...props
30
35
  }: SidebarProps) {
31
36
  return (
32
- <aside
33
- className={cn(
34
- "flex h-full flex-col border-e bg-background transition-all duration-200",
35
- collapsed ? "w-16" : widthClasses[width],
36
- className
37
- )}
38
- {...props}
39
- >
40
- {children}
41
- </aside>
37
+ <SidebarCollapsedChangeContext.Provider value={onCollapsedChange}>
38
+ <aside
39
+ className={cn(
40
+ "flex h-full flex-col border-e bg-background transition-all duration-200",
41
+ collapsed ? "w-16" : widthClasses[width],
42
+ className
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ </aside>
48
+ </SidebarCollapsedChangeContext.Provider>
42
49
  )
43
50
  }
44
51
 
@@ -174,9 +181,19 @@ export function SidebarCollapseButton({
174
181
  className,
175
182
  ...props
176
183
  }: SidebarCollapseButtonProps) {
184
+ const onSidebarCollapsedChange = React.useContext(SidebarCollapsedChangeContext)
185
+
186
+ // Tells the enclosing Sidebar too, once if both were given the same handler
187
+ const toggle = () => {
188
+ onCollapsedChange(!collapsed)
189
+ if (onSidebarCollapsedChange && onSidebarCollapsedChange !== onCollapsedChange) {
190
+ onSidebarCollapsedChange(!collapsed)
191
+ }
192
+ }
193
+
177
194
  return (
178
195
  <button
179
- onClick={() => onCollapsedChange(!collapsed)}
196
+ onClick={toggle}
180
197
  className={cn(
181
198
  "flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent",
182
199
  className
@@ -60,8 +60,9 @@ export function StatsCard({
60
60
  <span
61
61
  className={cn(
62
62
  "inline-flex items-center gap-1 font-medium",
63
- trend.direction === "up" && "text-green-600 dark:text-green-400",
64
- trend.direction === "down" && "text-red-600 dark:text-red-400",
63
+ // The tokens already change with the mode, so no dark: variants.
64
+ trend.direction === "up" && "text-success",
65
+ trend.direction === "down" && "text-destructive",
65
66
  trend.direction === "neutral" && "text-muted-foreground"
66
67
  )}
67
68
  >
@@ -13,14 +13,10 @@ const filledList = (colors: string[] | undefined, fallback: string[]) => {
13
13
  return usable.length ? usable : fallback
14
14
  }
15
15
 
16
- const GRADIENT_COLORS = [
17
- "var(--primary)",
18
- "var(--secondary)",
19
- "hsl(280 100% 70%)",
20
- "hsl(200 100% 70%)",
21
- ]
16
+ // Theme tokens only, so the gradient follows the project's palette and mode.
17
+ const GRADIENT_COLORS = ["var(--primary)", "var(--secondary)", "var(--chart-5)", "var(--info)"]
22
18
 
23
- const TEXT_COLORS = ["var(--primary)", "hsl(280 100% 70%)", "var(--primary)"]
19
+ const TEXT_COLORS = ["var(--primary)", "var(--chart-5)", "var(--primary)"]
24
20
 
25
21
  export interface AnimatedGradientProps
26
22
  extends React.HTMLAttributes<HTMLDivElement> {