@quadrokit/ui 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/README.md +32 -0
- package/package.json +41 -0
- package/src/components/button.tsx +45 -0
- package/src/components/card.tsx +50 -0
- package/src/components/input.tsx +22 -0
- package/src/components/label.tsx +18 -0
- package/src/components/theme-toolbar.tsx +46 -0
- package/src/index.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +93 -0
- package/src/theme/theme-provider.tsx +101 -0
- package/src/theme/themes.ts +9 -0
- package/tailwind-preset.ts +51 -0
- package/tsconfig.json +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# `@quadrokit/ui`
|
|
2
|
+
|
|
3
|
+
Shared **React** UI for QuadroKit templates: Tailwind **preset**, global **CSS variables** (light/dark + theme presets), and a few **shadcn-style** primitives (Radix Slot, CVA, `cn()`).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
1. Add the dependency and **Tailwind 3.4** in your app.
|
|
8
|
+
2. In `tailwind.config.ts`, use the preset:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import preset from '@quadrokit/ui/tailwind-preset';
|
|
12
|
+
export default { presets: [preset], content: [/* your files */, '../../node_modules/@quadrokit/ui/src/**/*.{ts,tsx}'] };
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
3. Import styles once (e.g. in `main.tsx`):
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import '@quadrokit/ui/styles.css';
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
4. Wrap the tree with `ThemeProvider` and use `ThemeToolbar`, `Button`, `Card`, etc.
|
|
22
|
+
|
|
23
|
+
## Themes
|
|
24
|
+
|
|
25
|
+
- **Color mode**: `light` | `dark` | `system` (via `ThemeProvider` / `useTheme()`).
|
|
26
|
+
- **Presets**: `ocean`, `forest`, `sunset`, `mono` (`data-quadro-theme` on `<html>`).
|
|
27
|
+
|
|
28
|
+
Adding a preset: extend [`src/styles.css`](src/styles.css) with new `html[data-quadro-theme='…']` blocks and register the id in [`src/theme/themes.ts`](src/theme/themes.ts).
|
|
29
|
+
|
|
30
|
+
## Peer dependencies
|
|
31
|
+
|
|
32
|
+
`react` and `react-dom` ^18 or ^19.
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quadrokit/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"**/*.css"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"import": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./styles.css": "./src/styles.css",
|
|
15
|
+
"./tailwind-preset": {
|
|
16
|
+
"types": "./tailwind-preset.ts",
|
|
17
|
+
"import": "./tailwind-preset.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
25
|
+
"class-variance-authority": "^0.7.1",
|
|
26
|
+
"clsx": "^2.1.1",
|
|
27
|
+
"tailwind-merge": "^3.5.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": "^18 || ^19",
|
|
31
|
+
"react-dom": "^18 || ^19"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^19.2.14",
|
|
35
|
+
"@types/react-dom": "^19.2.3",
|
|
36
|
+
"react": "^19.2.4",
|
|
37
|
+
"react-dom": "^19.2.4",
|
|
38
|
+
"tailwindcss": "^3.4.17",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
+
import type { ButtonHTMLAttributes } from 'react'
|
|
4
|
+
import { forwardRef } from 'react'
|
|
5
|
+
import { cn } from '../lib/utils.js'
|
|
6
|
+
|
|
7
|
+
const variants = cva(
|
|
8
|
+
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
|
13
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
14
|
+
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
15
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
16
|
+
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
|
17
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
default: 'h-9 px-4 py-2',
|
|
21
|
+
sm: 'h-8 rounded-md px-3 text-xs',
|
|
22
|
+
lg: 'h-10 rounded-md px-8',
|
|
23
|
+
icon: 'h-9 w-9',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
variant: 'default',
|
|
28
|
+
size: 'default',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export interface ButtonProps
|
|
34
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
|
35
|
+
VariantProps<typeof variants> {
|
|
36
|
+
asChild?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
40
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
41
|
+
const Comp = asChild ? Slot : 'button'
|
|
42
|
+
return <Comp className={cn(variants({ variant, size, className }))} ref={ref} {...props} />
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
Button.displayName = 'Button'
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
import { forwardRef } from 'react'
|
|
3
|
+
import { cn } from '../lib/utils.js'
|
|
4
|
+
|
|
5
|
+
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
6
|
+
({ className, ...props }, ref) => (
|
|
7
|
+
<div
|
|
8
|
+
ref={ref}
|
|
9
|
+
className={cn(
|
|
10
|
+
'rounded-xl border border-border bg-card text-card-foreground shadow-sm',
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
Card.displayName = 'Card'
|
|
18
|
+
|
|
19
|
+
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
20
|
+
({ className, ...props }, ref) => (
|
|
21
|
+
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
CardHeader.displayName = 'CardHeader'
|
|
25
|
+
|
|
26
|
+
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
|
27
|
+
({ className, ...props }, ref) => (
|
|
28
|
+
<h3
|
|
29
|
+
ref={ref}
|
|
30
|
+
className={cn('font-semibold leading-none tracking-tight', className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
CardTitle.displayName = 'CardTitle'
|
|
36
|
+
|
|
37
|
+
export const CardDescription = forwardRef<
|
|
38
|
+
HTMLParagraphElement,
|
|
39
|
+
HTMLAttributes<HTMLParagraphElement>
|
|
40
|
+
>(({ className, ...props }, ref) => (
|
|
41
|
+
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
|
42
|
+
))
|
|
43
|
+
CardDescription.displayName = 'CardDescription'
|
|
44
|
+
|
|
45
|
+
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
46
|
+
({ className, ...props }, ref) => (
|
|
47
|
+
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
CardContent.displayName = 'CardContent'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from 'react'
|
|
2
|
+
import { forwardRef } from 'react'
|
|
3
|
+
import { cn } from '../lib/utils.js'
|
|
4
|
+
|
|
5
|
+
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
|
6
|
+
|
|
7
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
8
|
+
({ className, type, ...props }, ref) => {
|
|
9
|
+
return (
|
|
10
|
+
<input
|
|
11
|
+
type={type}
|
|
12
|
+
className={cn(
|
|
13
|
+
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
ref={ref}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
Input.displayName = 'Input'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LabelHTMLAttributes } from 'react'
|
|
2
|
+
import { forwardRef } from 'react'
|
|
3
|
+
import { cn } from '../lib/utils.js'
|
|
4
|
+
|
|
5
|
+
export const Label = forwardRef<HTMLLabelElement, LabelHTMLAttributes<HTMLLabelElement>>(
|
|
6
|
+
({ className, ...props }, ref) => (
|
|
7
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: shadcn-style primitive; use htmlFor at call sites
|
|
8
|
+
<label
|
|
9
|
+
ref={ref}
|
|
10
|
+
className={cn(
|
|
11
|
+
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
|
12
|
+
className
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
Label.displayName = 'Label'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useTheme } from '../theme/theme-provider.js'
|
|
2
|
+
import { type QuadroThemePreset, quadroThemes, themeLabels } from '../theme/themes.js'
|
|
3
|
+
import { Button } from './button.js'
|
|
4
|
+
|
|
5
|
+
export function ThemeToolbar() {
|
|
6
|
+
const { preset, setPreset, colorMode, setColorMode, resolvedMode } = useTheme()
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card/60 p-2 text-sm backdrop-blur">
|
|
10
|
+
<span className="text-muted-foreground px-1">Theme</span>
|
|
11
|
+
{quadroThemes.map((t: QuadroThemePreset) => (
|
|
12
|
+
<Button
|
|
13
|
+
key={t}
|
|
14
|
+
type="button"
|
|
15
|
+
size="sm"
|
|
16
|
+
variant={preset === t ? 'default' : 'outline'}
|
|
17
|
+
onClick={() => setPreset(t)}
|
|
18
|
+
>
|
|
19
|
+
{themeLabels[t]}
|
|
20
|
+
</Button>
|
|
21
|
+
))}
|
|
22
|
+
<span className="mx-2 hidden h-4 w-px bg-border sm:inline-block" />
|
|
23
|
+
<span className="text-muted-foreground px-1">Mode</span>
|
|
24
|
+
{(
|
|
25
|
+
[
|
|
26
|
+
['light', 'Light'],
|
|
27
|
+
['dark', 'Dark'],
|
|
28
|
+
['system', 'System'],
|
|
29
|
+
] as const
|
|
30
|
+
).map(([m, label]) => (
|
|
31
|
+
<Button
|
|
32
|
+
key={m}
|
|
33
|
+
type="button"
|
|
34
|
+
size="sm"
|
|
35
|
+
variant={colorMode === m ? 'default' : 'ghost'}
|
|
36
|
+
onClick={() => setColorMode(m)}
|
|
37
|
+
>
|
|
38
|
+
{label}
|
|
39
|
+
</Button>
|
|
40
|
+
))}
|
|
41
|
+
<span className="ml-auto text-xs text-muted-foreground">
|
|
42
|
+
{resolvedMode === 'dark' ? 'Dark' : 'Light'}
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { Button, type ButtonProps } from './components/button.js'
|
|
2
|
+
export {
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardDescription,
|
|
6
|
+
CardHeader,
|
|
7
|
+
CardTitle,
|
|
8
|
+
} from './components/card.js'
|
|
9
|
+
export { Input, type InputProps } from './components/input.js'
|
|
10
|
+
export { Label } from './components/label.js'
|
|
11
|
+
export { ThemeToolbar } from './components/theme-toolbar.js'
|
|
12
|
+
export { cn } from './lib/utils.js'
|
|
13
|
+
export {
|
|
14
|
+
type ColorMode,
|
|
15
|
+
ThemeProvider,
|
|
16
|
+
useTheme,
|
|
17
|
+
} from './theme/theme-provider.js'
|
|
18
|
+
export {
|
|
19
|
+
type QuadroThemePreset,
|
|
20
|
+
quadroThemes,
|
|
21
|
+
themeLabels,
|
|
22
|
+
} from './theme/themes.js'
|
package/src/lib/utils.ts
ADDED
package/src/styles.css
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--background: 210 40% 98%;
|
|
7
|
+
--foreground: 222 47% 11%;
|
|
8
|
+
--card: 0 0% 100%;
|
|
9
|
+
--card-foreground: 222 47% 11%;
|
|
10
|
+
--popover: 0 0% 100%;
|
|
11
|
+
--popover-foreground: 222 47% 11%;
|
|
12
|
+
--primary: 199 89% 48%;
|
|
13
|
+
--primary-foreground: 210 40% 98%;
|
|
14
|
+
--secondary: 210 40% 96%;
|
|
15
|
+
--secondary-foreground: 222 47% 11%;
|
|
16
|
+
--muted: 210 40% 96%;
|
|
17
|
+
--muted-foreground: 215 16% 47%;
|
|
18
|
+
--accent: 210 40% 96%;
|
|
19
|
+
--accent-foreground: 222 47% 11%;
|
|
20
|
+
--destructive: 0 84% 60%;
|
|
21
|
+
--destructive-foreground: 210 40% 98%;
|
|
22
|
+
--border: 214 32% 91%;
|
|
23
|
+
--input: 214 32% 91%;
|
|
24
|
+
--ring: 199 89% 48%;
|
|
25
|
+
--radius: 0.6rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.dark {
|
|
29
|
+
--background: 222 47% 6%;
|
|
30
|
+
--foreground: 210 40% 98%;
|
|
31
|
+
--card: 222 47% 8%;
|
|
32
|
+
--card-foreground: 210 40% 98%;
|
|
33
|
+
--popover: 222 47% 8%;
|
|
34
|
+
--popover-foreground: 210 40% 98%;
|
|
35
|
+
--primary: 199 89% 55%;
|
|
36
|
+
--primary-foreground: 222 47% 6%;
|
|
37
|
+
--secondary: 217 33% 14%;
|
|
38
|
+
--secondary-foreground: 210 40% 98%;
|
|
39
|
+
--muted: 217 33% 14%;
|
|
40
|
+
--muted-foreground: 215 20% 65%;
|
|
41
|
+
--accent: 217 33% 14%;
|
|
42
|
+
--accent-foreground: 210 40% 98%;
|
|
43
|
+
--destructive: 0 63% 45%;
|
|
44
|
+
--destructive-foreground: 210 40% 98%;
|
|
45
|
+
--border: 217 33% 18%;
|
|
46
|
+
--input: 217 33% 18%;
|
|
47
|
+
--ring: 199 89% 55%;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
html[data-quadro-theme="ocean"] {
|
|
51
|
+
--primary: 199 89% 48%;
|
|
52
|
+
--ring: 199 89% 48%;
|
|
53
|
+
}
|
|
54
|
+
html.dark[data-quadro-theme="ocean"] {
|
|
55
|
+
--primary: 199 89% 55%;
|
|
56
|
+
--ring: 199 89% 55%;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
html[data-quadro-theme="forest"] {
|
|
60
|
+
--primary: 142 72% 35%;
|
|
61
|
+
--ring: 142 72% 35%;
|
|
62
|
+
}
|
|
63
|
+
html.dark[data-quadro-theme="forest"] {
|
|
64
|
+
--primary: 142 65% 48%;
|
|
65
|
+
--ring: 142 65% 48%;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
html[data-quadro-theme="sunset"] {
|
|
69
|
+
--primary: 24 95% 53%;
|
|
70
|
+
--ring: 24 95% 53%;
|
|
71
|
+
}
|
|
72
|
+
html.dark[data-quadro-theme="sunset"] {
|
|
73
|
+
--primary: 24 90% 60%;
|
|
74
|
+
--ring: 24 90% 60%;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
html[data-quadro-theme="mono"] {
|
|
78
|
+
--primary: 222 47% 11%;
|
|
79
|
+
--ring: 222 47% 11%;
|
|
80
|
+
}
|
|
81
|
+
html.dark[data-quadro-theme="mono"] {
|
|
82
|
+
--primary: 210 40% 96%;
|
|
83
|
+
--ring: 210 40% 96%;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@layer base {
|
|
87
|
+
* {
|
|
88
|
+
@apply border-border;
|
|
89
|
+
}
|
|
90
|
+
body {
|
|
91
|
+
@apply bg-background text-foreground antialiased;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import type { QuadroThemePreset } from './themes.js'
|
|
11
|
+
|
|
12
|
+
export type ColorMode = 'light' | 'dark' | 'system'
|
|
13
|
+
|
|
14
|
+
interface ThemeContextValue {
|
|
15
|
+
preset: QuadroThemePreset
|
|
16
|
+
setPreset: (p: QuadroThemePreset) => void
|
|
17
|
+
colorMode: ColorMode
|
|
18
|
+
setColorMode: (m: ColorMode) => void
|
|
19
|
+
resolvedMode: 'light' | 'dark'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
23
|
+
|
|
24
|
+
const STORAGE_KEY = 'quadrokit:theme'
|
|
25
|
+
const MODE_KEY = 'quadrokit:color-mode'
|
|
26
|
+
|
|
27
|
+
function readStored<T>(key: string, fallback: T): T {
|
|
28
|
+
if (typeof window === 'undefined') {
|
|
29
|
+
return fallback
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const raw = localStorage.getItem(key)
|
|
33
|
+
return raw ? (JSON.parse(raw) as T) : fallback
|
|
34
|
+
} catch {
|
|
35
|
+
return fallback
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function applyDom(preset: QuadroThemePreset, resolved: 'light' | 'dark') {
|
|
40
|
+
const root = document.documentElement
|
|
41
|
+
root.dataset.quadroTheme = preset
|
|
42
|
+
root.classList.toggle('dark', resolved === 'dark')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ThemeProvider({
|
|
46
|
+
children,
|
|
47
|
+
defaultPreset = 'ocean',
|
|
48
|
+
defaultColorMode = 'system',
|
|
49
|
+
}: {
|
|
50
|
+
children: ReactNode
|
|
51
|
+
defaultPreset?: QuadroThemePreset
|
|
52
|
+
defaultColorMode?: ColorMode
|
|
53
|
+
}) {
|
|
54
|
+
const [preset, setPresetState] = useState<QuadroThemePreset>(() =>
|
|
55
|
+
readStored(STORAGE_KEY, defaultPreset)
|
|
56
|
+
)
|
|
57
|
+
const [colorMode, setColorModeState] = useState<ColorMode>(() =>
|
|
58
|
+
readStored(MODE_KEY, defaultColorMode)
|
|
59
|
+
)
|
|
60
|
+
const [systemDark, setSystemDark] = useState(false)
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
64
|
+
const sync = () => setSystemDark(mq.matches)
|
|
65
|
+
sync()
|
|
66
|
+
mq.addEventListener('change', sync)
|
|
67
|
+
return () => mq.removeEventListener('change', sync)
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
const resolvedMode: 'light' | 'dark' =
|
|
71
|
+
colorMode === 'system' ? (systemDark ? 'dark' : 'light') : colorMode
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
applyDom(preset, resolvedMode)
|
|
75
|
+
}, [preset, resolvedMode])
|
|
76
|
+
|
|
77
|
+
const setPreset = useCallback((p: QuadroThemePreset) => {
|
|
78
|
+
setPresetState(p)
|
|
79
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(p))
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
const setColorMode = useCallback((m: ColorMode) => {
|
|
83
|
+
setColorModeState(m)
|
|
84
|
+
localStorage.setItem(MODE_KEY, JSON.stringify(m))
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const value = useMemo(
|
|
88
|
+
() => ({ preset, setPreset, colorMode, setColorMode, resolvedMode }),
|
|
89
|
+
[preset, setPreset, colorMode, setColorMode, resolvedMode]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useTheme(): ThemeContextValue {
|
|
96
|
+
const ctx = useContext(ThemeContext)
|
|
97
|
+
if (!ctx) {
|
|
98
|
+
throw new Error('useTheme must be used within ThemeProvider')
|
|
99
|
+
}
|
|
100
|
+
return ctx
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const quadroThemes = ['ocean', 'forest', 'sunset', 'mono'] as const
|
|
2
|
+
export type QuadroThemePreset = (typeof quadroThemes)[number]
|
|
3
|
+
|
|
4
|
+
export const themeLabels: Record<QuadroThemePreset, string> = {
|
|
5
|
+
ocean: 'Ocean',
|
|
6
|
+
forest: 'Forest',
|
|
7
|
+
sunset: 'Sunset',
|
|
8
|
+
mono: 'Mono',
|
|
9
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Config } from 'tailwindcss'
|
|
2
|
+
|
|
3
|
+
const preset: Partial<Config> = {
|
|
4
|
+
darkMode: 'class',
|
|
5
|
+
theme: {
|
|
6
|
+
extend: {
|
|
7
|
+
borderRadius: {
|
|
8
|
+
lg: 'var(--radius)',
|
|
9
|
+
md: 'calc(var(--radius) - 2px)',
|
|
10
|
+
sm: 'calc(var(--radius) - 4px)',
|
|
11
|
+
},
|
|
12
|
+
colors: {
|
|
13
|
+
border: 'hsl(var(--border) / <alpha-value>)',
|
|
14
|
+
input: 'hsl(var(--input) / <alpha-value>)',
|
|
15
|
+
ring: 'hsl(var(--ring) / <alpha-value>)',
|
|
16
|
+
background: 'hsl(var(--background) / <alpha-value>)',
|
|
17
|
+
foreground: 'hsl(var(--foreground) / <alpha-value>)',
|
|
18
|
+
primary: {
|
|
19
|
+
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
|
|
20
|
+
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
|
|
21
|
+
},
|
|
22
|
+
secondary: {
|
|
23
|
+
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
|
|
24
|
+
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)',
|
|
25
|
+
},
|
|
26
|
+
destructive: {
|
|
27
|
+
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
|
|
28
|
+
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)',
|
|
29
|
+
},
|
|
30
|
+
muted: {
|
|
31
|
+
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
|
|
32
|
+
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
|
|
33
|
+
},
|
|
34
|
+
accent: {
|
|
35
|
+
DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
|
|
36
|
+
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)',
|
|
37
|
+
},
|
|
38
|
+
popover: {
|
|
39
|
+
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
|
|
40
|
+
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)',
|
|
41
|
+
},
|
|
42
|
+
card: {
|
|
43
|
+
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
|
|
44
|
+
foreground: 'hsl(var(--card-foreground) / <alpha-value>)',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default preset
|