@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 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'
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
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
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "noEmit": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
7
+ },
8
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
9
+ }