canopy-ui 0.2.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/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "canopy-ui",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./lib": "./src/lib/index.ts",
8
+ "./ui": "./src/ui/index.ts",
9
+ "./shell": "./src/shell/index.ts",
10
+ "./tokens": "./src/tokens/index.ts",
11
+ "./tokens/preset.css": "./src/tokens/preset.css",
12
+ "./tokens/tokens.css": "./src/tokens/tokens.css"
13
+ },
14
+ "publishConfig": {
15
+ "registry": "https://registry.npmjs.org",
16
+ "access": "public"
17
+ },
18
+ "peerDependencies": {
19
+ "@base-ui/react": "^1.3.0",
20
+ "class-variance-authority": "^0.7.1",
21
+ "clsx": "^2.1.1",
22
+ "react": "^19.0.0",
23
+ "react-dom": "^19.0.0",
24
+ "tailwind-merge": "^3.5.0"
25
+ },
26
+ "files": ["src"]
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // The full canopy-ui surface, re-exported from the subpath modules.
2
+ // Prefer the subpath imports (canopy-ui/ui, /shell, /lib) in app code;
3
+ // this barrel keeps the convenience top-level entry working.
4
+ export * from './lib'
5
+ export * from './shell'
6
+ export * from './ui'
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cn } from './cn'
3
+
4
+ describe('cn', () => {
5
+ it('merges conditional classes', () => {
6
+ expect(cn('a', false && 'b', 'c')).toBe('a c')
7
+ })
8
+ it('lets later tailwind classes win', () => {
9
+ expect(cn('px-2', 'px-4')).toBe('px-4')
10
+ })
11
+ })
package/src/lib/cn.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]): string {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1 @@
1
+ export { cn } from './cn'
@@ -0,0 +1,17 @@
1
+ import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'
2
+ import { cn } from '../lib/cn'
3
+
4
+ /**
5
+ * The scrolling main column. Forwards a ref and arbitrary <main> props so a
6
+ * surface can mark it as a scroll-spy root (e.g. data-ddd-scroll).
7
+ */
8
+ export const WorkbenchMain = forwardRef<
9
+ HTMLElement,
10
+ ComponentPropsWithoutRef<'main'> & { children: ReactNode }
11
+ >(function WorkbenchMain({ children, className, ...rest }, ref) {
12
+ return (
13
+ <main ref={ref} className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto', className)} {...rest}>
14
+ {children}
15
+ </main>
16
+ )
17
+ })
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { workbenchNavItemClass } from './WorkbenchNavItem'
3
+
4
+ describe('workbenchNavItemClass', () => {
5
+ it('uses the orange-tinted active treatment when active', () => {
6
+ const c = workbenchNavItemClass({ active: true })
7
+ expect(c).toContain('bg-primary/10')
8
+ expect(c).toContain('border-primary/30')
9
+ expect(c).toContain('text-primary')
10
+ })
11
+ it('uses the muted resting treatment when inactive', () => {
12
+ const c = workbenchNavItemClass({ active: false })
13
+ expect(c).toContain('text-muted-foreground')
14
+ expect(c).toContain('hover:bg-accent')
15
+ expect(c).not.toContain('bg-primary/10')
16
+ })
17
+ it('uses the neutral grey highlight when variant is neutral and active', () => {
18
+ const c = workbenchNavItemClass({ active: true, variant: 'neutral' })
19
+ expect(c).toContain('bg-accent')
20
+ expect(c).toContain('text-foreground')
21
+ expect(c).not.toContain('bg-primary/10')
22
+ })
23
+ it('defaults to the accent variant', () => {
24
+ expect(workbenchNavItemClass({ active: true })).toBe(
25
+ workbenchNavItemClass({ active: true, variant: 'accent' }),
26
+ )
27
+ })
28
+ })
@@ -0,0 +1,72 @@
1
+ import { cloneElement, isValidElement, type JSX, type ReactElement, type ReactNode } from 'react'
2
+ import { cn } from '../lib/cn'
3
+
4
+ export function workbenchNavItemClass({
5
+ active,
6
+ variant = 'accent',
7
+ }: {
8
+ active?: boolean
9
+ variant?: 'accent' | 'neutral'
10
+ }): string {
11
+ const activeClass =
12
+ variant === 'neutral'
13
+ ? 'bg-accent border-transparent text-foreground font-medium'
14
+ : 'bg-primary/10 border-primary/30 text-primary font-medium'
15
+ return cn(
16
+ 'flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors',
17
+ active
18
+ ? activeClass
19
+ : 'border-transparent text-muted-foreground hover:bg-accent hover:text-foreground',
20
+ )
21
+ }
22
+
23
+ interface WorkbenchNavItemProps {
24
+ active?: boolean
25
+ /** Active-state accent: 'accent' = orange primary tint (default), 'neutral' = grey highlight. */
26
+ variant?: 'accent' | 'neutral'
27
+ count?: number
28
+ /** When true, merge styling onto the single child element (e.g. a router Link). */
29
+ asChild?: boolean
30
+ children: ReactNode
31
+ }
32
+
33
+ /**
34
+ * One rail entry: label + optional right-aligned count badge + active state.
35
+ *
36
+ * Default form: renders a presentational <div>; the caller wraps it in their own
37
+ * Link/NavLink. asChild form: clones the single child, merges styling onto it,
38
+ * and uses the child's own text as the label (router-agnostic; no radix dep).
39
+ */
40
+ export function WorkbenchNavItem({
41
+ active,
42
+ variant,
43
+ count,
44
+ asChild,
45
+ children,
46
+ }: WorkbenchNavItemProps): JSX.Element {
47
+ const className = workbenchNavItemClass({ active, variant })
48
+ const badge =
49
+ count !== undefined ? (
50
+ <span className="shrink-0 text-[11px] text-muted-foreground">{count}</span>
51
+ ) : null
52
+
53
+ // asChild requires a single valid element; a non-element child falls back to the div form.
54
+ if (asChild && isValidElement(children)) {
55
+ const el = children as ReactElement<{ className?: string; children?: ReactNode }>
56
+ return cloneElement(
57
+ el,
58
+ { className: cn(className, el.props.className) },
59
+ <>
60
+ <span className="truncate">{el.props.children}</span>
61
+ {badge}
62
+ </>,
63
+ )
64
+ }
65
+
66
+ return (
67
+ <div className={className}>
68
+ <span className="truncate">{children}</span>
69
+ {badge}
70
+ </div>
71
+ )
72
+ }
@@ -0,0 +1,29 @@
1
+ import type { JSX, ReactNode } from 'react'
2
+ import { cn } from '../lib/cn'
3
+
4
+ /** Shipped for the deferred ace-web adoption; not yet consumed in canopy. */
5
+ /** A generic bordered side panel (e.g. a detail or chat column). */
6
+ export function WorkbenchPane({
7
+ width,
8
+ side = 'left',
9
+ children,
10
+ className,
11
+ }: {
12
+ width?: string
13
+ side?: 'left' | 'right'
14
+ children: ReactNode
15
+ className?: string
16
+ }): JSX.Element {
17
+ return (
18
+ <section
19
+ className={cn(
20
+ 'shrink-0 bg-background',
21
+ side === 'right' ? 'border-l border-border' : 'border-r border-border',
22
+ width,
23
+ className,
24
+ )}
25
+ >
26
+ {children}
27
+ </section>
28
+ )
29
+ }
@@ -0,0 +1,37 @@
1
+ import type { JSX, ReactNode } from 'react'
2
+ import { cn } from '../lib/cn'
3
+
4
+ /**
5
+ * The bordered left rail chrome. Header slot (identity / title / filters) over a
6
+ * scrollable body that takes arbitrary children (a tree or a flat list).
7
+ */
8
+ export function WorkbenchRail({
9
+ // `width` must be a md-prefixed Tailwind width (e.g. `md:w-64`): on phones the
10
+ // rail spans full width and stacks above the main column, so the width only
11
+ // applies once the layout goes side-by-side at md+.
12
+ width = 'md:w-64',
13
+ header,
14
+ children,
15
+ className,
16
+ }: {
17
+ width?: string
18
+ header?: ReactNode
19
+ children: ReactNode
20
+ className?: string
21
+ }): JSX.Element {
22
+ return (
23
+ <aside
24
+ className={cn(
25
+ 'flex shrink-0 flex-col bg-background',
26
+ // Phone: full-width strip with a capped height so the main column below
27
+ // stays reachable. Desktop: fixed-width left rail with a right border.
28
+ 'w-full max-h-[42vh] border-b border-border md:max-h-none md:border-b-0 md:border-r',
29
+ width,
30
+ className,
31
+ )}
32
+ >
33
+ {header && <div className="border-b border-border">{header}</div>}
34
+ <div className="flex-1 overflow-y-auto">{children}</div>
35
+ </aside>
36
+ )
37
+ }
@@ -0,0 +1,26 @@
1
+ import type { JSX, ReactNode } from 'react'
2
+ import { cn } from '../lib/cn'
3
+
4
+ /**
5
+ * Full-bleed outer scaffold: optional top header over a body row. The caller
6
+ * composes the body (rail + main + optional side panes) as children, and may
7
+ * wrap <WorkbenchShell> in its own provider (e.g. for scroll-spy).
8
+ */
9
+ export function WorkbenchShell({
10
+ header,
11
+ children,
12
+ className,
13
+ }: {
14
+ header?: ReactNode
15
+ children: ReactNode
16
+ className?: string
17
+ }): JSX.Element {
18
+ return (
19
+ <div className={cn('flex h-full flex-col bg-background text-foreground', className)}>
20
+ {header}
21
+ {/* Rail + main sit side-by-side on desktop; on phones they stack so the
22
+ main column gets full width instead of being crushed by the rail. */}
23
+ <div className="flex flex-1 flex-col overflow-hidden md:flex-row">{children}</div>
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,24 @@
1
+ import type { JSX, ReactNode } from 'react'
2
+
3
+ /** The per-section bar inside the main area: title + count + right action. */
4
+ export function WorkbenchSubHeader({
5
+ title,
6
+ count,
7
+ action,
8
+ }: {
9
+ title: string
10
+ count?: number
11
+ action?: ReactNode
12
+ }): JSX.Element {
13
+ return (
14
+ <div className="mb-6 flex items-center justify-between gap-3 border-b border-border pb-4">
15
+ <div className="flex min-w-0 items-baseline gap-2">
16
+ <h1 className="text-base font-semibold text-foreground">{title}</h1>
17
+ {count !== undefined && (
18
+ <span className="text-[12px] text-muted-foreground">{count}</span>
19
+ )}
20
+ </div>
21
+ {action && <div className="shrink-0">{action}</div>}
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,9 @@
1
+ export { WorkbenchShell } from './WorkbenchShell'
2
+ export { WorkbenchMain } from './WorkbenchMain'
3
+ export { WorkbenchRail } from './WorkbenchRail'
4
+ export { WorkbenchPane } from './WorkbenchPane'
5
+ export { WorkbenchNavItem, workbenchNavItemClass } from './WorkbenchNavItem'
6
+ export { WorkbenchSubHeader } from './WorkbenchSubHeader'
7
+ export { LoadingSpinner, EmptyState, ErrorState, WorkbenchSkeleton } from './states'
8
+ export { usePaneWidth } from './usePaneWidth'
9
+ export { usePaneCollapsed } from './usePaneCollapsed'
@@ -0,0 +1,69 @@
1
+ import type { JSX, ReactNode } from 'react'
2
+
3
+ /** LoadingSpinner/EmptyState/ErrorState are shipped for consumers (e.g. deferred ace-web); WorkbenchSkeleton is used by the Agent sections today. */
4
+ export function LoadingSpinner({ label = 'Loading…' }: { label?: string }): JSX.Element {
5
+ return (
6
+ <div className="flex items-center gap-3 p-6 text-muted-foreground">
7
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
8
+ <span>{label}</span>
9
+ </div>
10
+ )
11
+ }
12
+
13
+ export function EmptyState({
14
+ title,
15
+ description,
16
+ action,
17
+ }: {
18
+ title: string
19
+ description?: string
20
+ action?: ReactNode
21
+ }): JSX.Element {
22
+ return (
23
+ <div className="flex flex-col items-center justify-center gap-2 p-12 text-center">
24
+ <h3 className="text-lg font-semibold text-muted-foreground">{title}</h3>
25
+ {description && <p className="text-sm text-muted-foreground">{description}</p>}
26
+ {action && <div className="mt-4">{action}</div>}
27
+ </div>
28
+ )
29
+ }
30
+
31
+ export function ErrorState({
32
+ title = 'Something went wrong',
33
+ message,
34
+ onRetry,
35
+ }: {
36
+ title?: string
37
+ message: string
38
+ onRetry?: () => void
39
+ }): JSX.Element {
40
+ return (
41
+ <div className="rounded border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
42
+ <div className="font-semibold">{title}</div>
43
+ <div className="mt-1">{message}</div>
44
+ {onRetry && (
45
+ <button
46
+ type="button"
47
+ onClick={onRetry}
48
+ className="mt-3 rounded bg-destructive px-3 py-1 text-destructive-foreground hover:bg-destructive/90"
49
+ >
50
+ Retry
51
+ </button>
52
+ )}
53
+ </div>
54
+ )
55
+ }
56
+
57
+ /** Pulsing card placeholders while a section lazy-loads its data. */
58
+ export function WorkbenchSkeleton({ rows = 3 }: { rows?: number }): JSX.Element {
59
+ return (
60
+ <div className="animate-pulse space-y-3">
61
+ {Array.from({ length: rows }).map((_, i) => (
62
+ <div key={i} className="rounded-xl border border-border bg-card p-5">
63
+ <div className="mb-2 h-4 w-2/3 rounded bg-muted" />
64
+ <div className="h-3 w-full rounded bg-muted/70" />
65
+ </div>
66
+ ))}
67
+ </div>
68
+ )
69
+ }
@@ -0,0 +1,35 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ /**
4
+ * localStorage-persisted collapse state for a workbench pane, keyed per
5
+ * pane so multiple rails on one page don't collide. Generalized from
6
+ * hooks/useChatPaneCollapsed.ts. Falls back to per-tab state when storage
7
+ * is unavailable (private mode).
8
+ */
9
+ export function usePaneCollapsed(
10
+ storageKey: string,
11
+ defaultCollapsed = false,
12
+ ): { collapsed: boolean; toggle: () => void; setCollapsed: (v: boolean) => void } {
13
+ const [collapsed, setCollapsed] = useState<boolean>(() => {
14
+ if (typeof window === "undefined") return defaultCollapsed;
15
+ try {
16
+ const raw = window.localStorage.getItem(storageKey);
17
+ if (raw === null) return defaultCollapsed;
18
+ return raw === "1";
19
+ } catch {
20
+ return defaultCollapsed;
21
+ }
22
+ });
23
+
24
+ useEffect(() => {
25
+ try {
26
+ window.localStorage.setItem(storageKey, collapsed ? "1" : "0");
27
+ } catch {
28
+ // storage disabled — preference is per-tab only
29
+ }
30
+ }, [storageKey, collapsed]);
31
+
32
+ const toggle = useCallback(() => setCollapsed((c) => !c), []);
33
+
34
+ return { collapsed, toggle, setCollapsed };
35
+ }
@@ -0,0 +1,35 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ /**
4
+ * localStorage-persisted width (px) for a resizable workbench rail, keyed
5
+ * per pane. Pair with WorkbenchRail's `resizable` + `onResize` to make a
6
+ * rail drag-to-resize. Falls back to per-tab state when storage is
7
+ * unavailable (private mode).
8
+ */
9
+ export function usePaneWidth(
10
+ storageKey: string,
11
+ defaultWidth: number,
12
+ ): { width: number; setWidth: (w: number) => void } {
13
+ const [width, setWidthState] = useState<number>(() => {
14
+ if (typeof window === "undefined") return defaultWidth;
15
+ try {
16
+ const raw = window.localStorage.getItem(storageKey);
17
+ const n = raw == null ? NaN : Number(raw);
18
+ return Number.isFinite(n) && n > 0 ? n : defaultWidth;
19
+ } catch {
20
+ return defaultWidth;
21
+ }
22
+ });
23
+
24
+ useEffect(() => {
25
+ try {
26
+ window.localStorage.setItem(storageKey, String(Math.round(width)));
27
+ } catch {
28
+ // storage disabled — width is per-tab only
29
+ }
30
+ }, [storageKey, width]);
31
+
32
+ const setWidth = useCallback((w: number) => setWidthState(w), []);
33
+
34
+ return { width, setWidth };
35
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * canopy-ui/tokens — the shared token contract.
3
+ *
4
+ * The substance of the token system is CSS, imported directly:
5
+ * import "canopy-ui/tokens/preset.css" // Tailwind v4 @theme name → var mapping
6
+ * import "canopy-ui/tokens/tokens.css" // default :root / .dark VALUES
7
+ *
8
+ * This module exposes the canonical list of token names so tooling/tests can
9
+ * assert an app's palette covers the contract.
10
+ */
11
+ export const TOKEN_NAMES = [
12
+ 'background',
13
+ 'foreground',
14
+ 'card',
15
+ 'card-foreground',
16
+ 'popover',
17
+ 'popover-foreground',
18
+ 'primary',
19
+ 'primary-foreground',
20
+ 'secondary',
21
+ 'secondary-foreground',
22
+ 'accent',
23
+ 'accent-foreground',
24
+ 'muted',
25
+ 'muted-foreground',
26
+ 'foreground-secondary',
27
+ 'foreground-subtle',
28
+ 'border',
29
+ 'input',
30
+ 'ring',
31
+ 'destructive',
32
+ 'destructive-foreground',
33
+ 'success',
34
+ 'success-foreground',
35
+ 'warning',
36
+ 'warning-foreground',
37
+ 'info',
38
+ 'info-foreground',
39
+ 'special',
40
+ 'special-foreground',
41
+ 'radius',
42
+ ] as const
43
+
44
+ export type TokenName = (typeof TOKEN_NAMES)[number]
@@ -0,0 +1,84 @@
1
+ /**
2
+ * canopy-ui — Tailwind v4 token PRESET.
3
+ *
4
+ * This is the shared CONTRACT: it maps Tailwind color/radius utilities
5
+ * (`bg-primary`, `text-muted-foreground`, `border-border`, `rounded-lg`, …)
6
+ * onto a fixed set of CSS-variable NAMES (`--primary`, `--muted-foreground`,
7
+ * `--border`, `--radius`, …). Every app that builds on the workbench imports
8
+ * this preset so its primitives (Button, Badge, Table, …) resolve to the same
9
+ * utility names. The preset owns the NAMES; each app owns the VALUES (palette),
10
+ * which it sets on `:root` / `.dark` in its own globals (see tokens.css for the
11
+ * default values, which an app may import then override).
12
+ *
13
+ * Usage (in an app's index.css, after `@import "tailwindcss";`):
14
+ * @import "canopy-ui/tokens/preset.css";
15
+ * @import "canopy-ui/tokens/tokens.css"; // optional defaults
16
+ * :root { --primary: <app palette>; ... } // override values
17
+ */
18
+ @theme inline {
19
+ /* Surfaces & text */
20
+ --color-background: var(--background);
21
+ --color-foreground: var(--foreground);
22
+ --color-card: var(--card);
23
+ --color-card-foreground: var(--card-foreground);
24
+ --color-popover: var(--popover);
25
+ --color-popover-foreground: var(--popover-foreground);
26
+
27
+ /* Brand / interactive */
28
+ --color-primary: var(--primary);
29
+ --color-primary-foreground: var(--primary-foreground);
30
+ --color-secondary: var(--secondary);
31
+ --color-secondary-foreground: var(--secondary-foreground);
32
+ --color-accent: var(--accent);
33
+ --color-accent-foreground: var(--accent-foreground);
34
+
35
+ /* Muted + extended neutral text scale (brightest → dimmest):
36
+ foreground > foreground-secondary > muted-foreground > foreground-subtle */
37
+ --color-muted: var(--muted);
38
+ --color-muted-foreground: var(--muted-foreground);
39
+ --color-foreground-secondary: var(--foreground-secondary);
40
+ --color-foreground-subtle: var(--foreground-subtle);
41
+
42
+ /* Lines & focus */
43
+ --color-border: var(--border);
44
+ --color-input: var(--input);
45
+ --color-ring: var(--ring);
46
+
47
+ /* Destructive + status / categorical accents (badges, chips, status states) */
48
+ --color-destructive: var(--destructive);
49
+ --color-destructive-foreground: var(--destructive-foreground);
50
+ --color-success: var(--success);
51
+ --color-success-foreground: var(--success-foreground);
52
+ --color-warning: var(--warning);
53
+ --color-warning-foreground: var(--warning-foreground);
54
+ --color-info: var(--info);
55
+ --color-info-foreground: var(--info-foreground);
56
+ --color-special: var(--special);
57
+ --color-special-foreground: var(--special-foreground);
58
+
59
+ /* Charts */
60
+ --color-chart-1: var(--chart-1);
61
+ --color-chart-2: var(--chart-2);
62
+ --color-chart-3: var(--chart-3);
63
+ --color-chart-4: var(--chart-4);
64
+ --color-chart-5: var(--chart-5);
65
+
66
+ /* Sidebar */
67
+ --color-sidebar: var(--sidebar);
68
+ --color-sidebar-foreground: var(--sidebar-foreground);
69
+ --color-sidebar-primary: var(--sidebar-primary);
70
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
71
+ --color-sidebar-accent: var(--sidebar-accent);
72
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
73
+ --color-sidebar-border: var(--sidebar-border);
74
+ --color-sidebar-ring: var(--sidebar-ring);
75
+
76
+ /* Radius scale — derived from the single --radius value the app sets. */
77
+ --radius-sm: calc(var(--radius) * 0.6);
78
+ --radius-md: calc(var(--radius) * 0.8);
79
+ --radius-lg: var(--radius);
80
+ --radius-xl: calc(var(--radius) * 1.4);
81
+ --radius-2xl: calc(var(--radius) * 1.8);
82
+ --radius-3xl: calc(var(--radius) * 2.2);
83
+ --radius-4xl: calc(var(--radius) * 2.6);
84
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * canopy-ui — DEFAULT token VALUES (the palette baseline).
3
+ *
4
+ * These are the default values for the CSS-variable names declared by the
5
+ * preset (preset.css). They are a complete, working "Warm Earth" palette so a
6
+ * consuming app renders correctly out of the box. Each app is expected to
7
+ * OVERRIDE these in its own globals (define `:root` / `.dark` AFTER importing
8
+ * this file); identical var names mean only the values shift per app.
9
+ *
10
+ * The app defaults to `.dark` (apps apply the `.dark` class pre-render); the
11
+ * `:root` light set is the opt-in companion. App-specific concerns (font face,
12
+ * custom animations) are NOT defined here — they live in each app's globals.
13
+ */
14
+ :root {
15
+ /* Warm Earth LIGHT — the opt-in companion. */
16
+ --background: oklch(0.985 0.002 106.42); /* warm off-white (~stone-50) */
17
+ --foreground: oklch(0.216 0.006 56.04); /* ~stone-900 */
18
+ --card: oklch(1 0 0); /* white */
19
+ --card-foreground: oklch(0.216 0.006 56.04);
20
+ --popover: oklch(1 0 0);
21
+ --popover-foreground: oklch(0.216 0.006 56.04);
22
+ --primary: oklch(0.646 0.222 41.12); /* ~orange-600 (readable on white) */
23
+ --primary-foreground: oklch(1 0 0);
24
+ --secondary: oklch(0.970 0.001 106.42); /* ~stone-100 */
25
+ --secondary-foreground: oklch(0.216 0.006 56.04);
26
+ --muted: oklch(0.970 0.001 106.42); /* ~stone-100 */
27
+ --muted-foreground: oklch(0.553 0.013 58.07); /* ~stone-500 */
28
+ --foreground-secondary: oklch(0.374 0.01 67.56); /* ~stone-700 — secondary text */
29
+ --foreground-subtle: oklch(0.709 0.01 56.26); /* ~stone-400 — faint text */
30
+ --accent: oklch(0.970 0.001 106.42);
31
+ --accent-foreground: oklch(0.216 0.006 56.04);
32
+ --destructive: oklch(0.577 0.245 27.325); /* ~red-600 */
33
+ --destructive-foreground: oklch(1 0 0);
34
+ --success: oklch(0.596 0.145 163.22); /* ~emerald-600 */
35
+ --success-foreground: oklch(1 0 0);
36
+ --warning: oklch(0.666 0.179 58.32); /* ~amber-600 */
37
+ --warning-foreground: oklch(1 0 0);
38
+ --info: oklch(0.588 0.158 241.97); /* ~sky-600 */
39
+ --info-foreground: oklch(1 0 0);
40
+ --special: oklch(0.541 0.281 293.0); /* ~violet-600 */
41
+ --special-foreground: oklch(1 0 0);
42
+ --border: oklch(0.869 0.005 56.37); /* ~stone-200 */
43
+ --input: oklch(0.869 0.005 56.37); /* ~stone-200 */
44
+ --ring: oklch(0.646 0.222 41.12 / 0.4); /* orange-600/40 */
45
+ --chart-1: oklch(0.646 0.222 41.12);
46
+ --chart-2: oklch(0.553 0.013 58.07);
47
+ --chart-3: oklch(0.709 0.01 56.26);
48
+ --chart-4: oklch(0.809 0.005 56.0);
49
+ --chart-5: oklch(0.869 0.005 56.37);
50
+ --radius: 0.625rem; /* theme-agnostic; inherited by .dark */
51
+ --sidebar: oklch(0.985 0.002 106.42);
52
+ --sidebar-foreground: oklch(0.216 0.006 56.04);
53
+ --sidebar-primary: oklch(0.646 0.222 41.12);
54
+ --sidebar-primary-foreground: oklch(1 0 0);
55
+ --sidebar-accent: oklch(0.970 0.001 106.42);
56
+ --sidebar-accent-foreground: oklch(0.216 0.006 56.04);
57
+ --sidebar-border: oklch(0.869 0.005 56.37);
58
+ --sidebar-ring: oklch(0.646 0.222 41.12 / 0.4);
59
+ }
60
+
61
+ .dark {
62
+ /* Warm Earth DARK — the default. */
63
+ --background: oklch(0.147 0.004 49.25); /* ~stone-950 */
64
+ --foreground: oklch(0.923 0.003 48.72); /* ~stone-100 */
65
+ --card: oklch(0.216 0.006 56.04); /* ~stone-900 */
66
+ --card-foreground: oklch(0.923 0.003 48.72);
67
+ --popover: oklch(0.216 0.006 56.04);
68
+ --popover-foreground: oklch(0.923 0.003 48.72);
69
+ --primary: oklch(0.757 0.161 53.57); /* orange-400 */
70
+ --primary-foreground: oklch(0.147 0.004 49.25);
71
+ --secondary: oklch(0.268 0.007 34.3); /* ~stone-800 */
72
+ --secondary-foreground: oklch(0.923 0.003 48.72);
73
+ --muted: oklch(0.268 0.007 34.3); /* ~stone-800 */
74
+ --muted-foreground: oklch(0.553 0.013 58.07);/* ~stone-500 */
75
+ --foreground-secondary: oklch(0.809 0.005 56.0); /* ~stone-300 — secondary text */
76
+ --foreground-subtle: oklch(0.374 0.01 67.56); /* ~stone-700 — faint text */
77
+ --accent: oklch(0.268 0.007 34.3);
78
+ --accent-foreground: oklch(0.923 0.003 48.72);
79
+ --destructive: oklch(0.704 0.191 22.216); /* ~red-500 — errors / destructive */
80
+ --destructive-foreground: oklch(0.985 0 0);
81
+ --success: oklch(0.765 0.177 163.22); /* ~emerald-400 — success / opportunity */
82
+ --success-foreground: oklch(0.147 0.004 49.25);
83
+ --warning: oklch(0.828 0.189 84.43); /* ~amber-400 — warning / ship-gap */
84
+ --warning-foreground: oklch(0.147 0.004 49.25);
85
+ --info: oklch(0.746 0.16 232.66); /* ~sky-400 — info / alignment */
86
+ --info-foreground: oklch(0.147 0.004 49.25);
87
+ --special: oklch(0.606 0.25 292.72); /* ~violet-400 — pattern / highlight */
88
+ --special-foreground: oklch(0.985 0 0);
89
+ --border: oklch(0.268 0.007 34.3); /* ~stone-800 */
90
+ --input: oklch(0.374 0.01 67.56); /* ~stone-700 */
91
+ --ring: oklch(0.757 0.161 53.57 / 0.4); /* orange-400/40 */
92
+ --chart-1: oklch(0.757 0.161 53.57);
93
+ --chart-2: oklch(0.553 0.013 58.07);
94
+ --chart-3: oklch(0.439 0 0);
95
+ --chart-4: oklch(0.371 0 0);
96
+ --chart-5: oklch(0.269 0 0);
97
+ --sidebar: oklch(0.147 0.004 49.25);
98
+ --sidebar-foreground: oklch(0.923 0.003 48.72);
99
+ --sidebar-primary: oklch(0.757 0.161 53.57);
100
+ --sidebar-primary-foreground: oklch(0.147 0.004 49.25);
101
+ --sidebar-accent: oklch(0.216 0.006 56.04);
102
+ --sidebar-accent-foreground: oklch(0.923 0.003 48.72);
103
+ --sidebar-border: oklch(0.268 0.007 34.3);
104
+ --sidebar-ring: oklch(0.757 0.161 53.57 / 0.4);
105
+ }
@@ -0,0 +1,52 @@
1
+ import { mergeProps } from "@base-ui/react/merge-props"
2
+ import { useRender } from "@base-ui/react/use-render"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "../lib/cn"
6
+
7
+ const badgeVariants = cva(
8
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
13
+ secondary:
14
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
15
+ destructive:
16
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
17
+ outline:
18
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
19
+ ghost:
20
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ variant: "default",
26
+ },
27
+ }
28
+ )
29
+
30
+ function Badge({
31
+ className,
32
+ variant = "default",
33
+ render,
34
+ ...props
35
+ }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
36
+ return useRender({
37
+ defaultTagName: "span",
38
+ props: mergeProps<"span">(
39
+ {
40
+ className: cn(badgeVariants({ variant }), className),
41
+ },
42
+ props
43
+ ),
44
+ render,
45
+ state: {
46
+ slot: "badge",
47
+ variant,
48
+ },
49
+ })
50
+ }
51
+
52
+ export { Badge, badgeVariants }
@@ -0,0 +1,58 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "../lib/cn"
5
+
6
+ const buttonVariants = cva(
7
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
12
+ outline:
13
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
14
+ secondary:
15
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
16
+ ghost:
17
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
18
+ destructive:
19
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default:
24
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
25
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
27
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
28
+ icon: "size-8",
29
+ "icon-xs":
30
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
31
+ "icon-sm":
32
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
33
+ "icon-lg": "size-9",
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ variant: "default",
38
+ size: "default",
39
+ },
40
+ }
41
+ )
42
+
43
+ function Button({
44
+ className,
45
+ variant = "default",
46
+ size = "default",
47
+ ...props
48
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
49
+ return (
50
+ <ButtonPrimitive
51
+ data-slot="button"
52
+ className={cn(buttonVariants({ variant, size, className }))}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ export { Button, buttonVariants }
@@ -0,0 +1,16 @@
1
+ export { Button, buttonVariants } from './button'
2
+ export { Badge, badgeVariants } from './badge'
3
+ export { Input } from './input'
4
+ export { Skeleton } from './skeleton'
5
+ export { Textarea } from './textarea'
6
+ export {
7
+ Table,
8
+ TableHeader,
9
+ TableBody,
10
+ TableFooter,
11
+ TableHead,
12
+ TableRow,
13
+ TableCell,
14
+ TableCaption,
15
+ } from './table'
16
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } from './tabs'
@@ -0,0 +1,20 @@
1
+ import * as React from "react"
2
+ import { Input as InputPrimitive } from "@base-ui/react/input"
3
+
4
+ import { cn } from "../lib/cn"
5
+
6
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
7
+ return (
8
+ <InputPrimitive
9
+ type={type}
10
+ data-slot="input"
11
+ className={cn(
12
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ export { Input }
@@ -0,0 +1,15 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../lib/cn"
4
+
5
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
6
+ return (
7
+ <div
8
+ data-slot="skeleton"
9
+ className={cn("animate-pulse rounded-md bg-muted", className)}
10
+ {...props}
11
+ />
12
+ )
13
+ }
14
+
15
+ export { Skeleton }
@@ -0,0 +1,114 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../lib/cn"
4
+
5
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
6
+ return (
7
+ <div
8
+ data-slot="table-container"
9
+ className="relative w-full overflow-x-auto rounded-lg border border-border bg-card"
10
+ >
11
+ <table
12
+ data-slot="table"
13
+ className={cn("w-full caption-bottom text-sm", className)}
14
+ {...props}
15
+ />
16
+ </div>
17
+ )
18
+ }
19
+
20
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
21
+ return (
22
+ <thead
23
+ data-slot="table-header"
24
+ className={cn("bg-background [&_tr]:border-b [&_tr]:border-border", className)}
25
+ {...props}
26
+ />
27
+ )
28
+ }
29
+
30
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
31
+ return (
32
+ <tbody
33
+ data-slot="table-body"
34
+ className={cn("[&_tr:last-child]:border-0", className)}
35
+ {...props}
36
+ />
37
+ )
38
+ }
39
+
40
+ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
41
+ return (
42
+ <tfoot
43
+ data-slot="table-footer"
44
+ className={cn(
45
+ "border-t border-border bg-background font-medium text-foreground-secondary [&>tr]:last:border-b-0",
46
+ className
47
+ )}
48
+ {...props}
49
+ />
50
+ )
51
+ }
52
+
53
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
54
+ return (
55
+ <tr
56
+ data-slot="table-row"
57
+ className={cn(
58
+ "border-b border-border transition-colors hover:bg-card/50 data-[state=selected]:bg-muted",
59
+ className
60
+ )}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+
66
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
67
+ return (
68
+ <th
69
+ data-slot="table-head"
70
+ className={cn(
71
+ "h-9 px-3 text-left align-middle text-[10px] font-semibold uppercase tracking-wider whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pr-0",
72
+ className
73
+ )}
74
+ {...props}
75
+ />
76
+ )
77
+ }
78
+
79
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
80
+ return (
81
+ <td
82
+ data-slot="table-cell"
83
+ className={cn(
84
+ "px-3 py-2.5 align-middle whitespace-nowrap text-foreground-secondary [&:has([role=checkbox])]:pr-0",
85
+ className
86
+ )}
87
+ {...props}
88
+ />
89
+ )
90
+ }
91
+
92
+ function TableCaption({
93
+ className,
94
+ ...props
95
+ }: React.ComponentProps<"caption">) {
96
+ return (
97
+ <caption
98
+ data-slot="table-caption"
99
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
100
+ {...props}
101
+ />
102
+ )
103
+ }
104
+
105
+ export {
106
+ Table,
107
+ TableHeader,
108
+ TableBody,
109
+ TableFooter,
110
+ TableHead,
111
+ TableRow,
112
+ TableCell,
113
+ TableCaption,
114
+ }
@@ -0,0 +1,82 @@
1
+ "use client"
2
+
3
+ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
4
+ import { cva, type VariantProps } from "class-variance-authority"
5
+
6
+ import { cn } from "../lib/cn"
7
+
8
+ function Tabs({
9
+ className,
10
+ orientation = "horizontal",
11
+ ...props
12
+ }: TabsPrimitive.Root.Props) {
13
+ return (
14
+ <TabsPrimitive.Root
15
+ data-slot="tabs"
16
+ data-orientation={orientation}
17
+ className={cn(
18
+ "group/tabs flex gap-2 data-horizontal:flex-col",
19
+ className
20
+ )}
21
+ {...props}
22
+ />
23
+ )
24
+ }
25
+
26
+ const tabsListVariants = cva(
27
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
28
+ {
29
+ variants: {
30
+ variant: {
31
+ default: "bg-card border border-border",
32
+ line: "gap-1 bg-transparent border-b border-border",
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ variant: "default",
37
+ },
38
+ }
39
+ )
40
+
41
+ function TabsList({
42
+ className,
43
+ variant = "default",
44
+ ...props
45
+ }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
46
+ return (
47
+ <TabsPrimitive.List
48
+ data-slot="tabs-list"
49
+ data-variant={variant}
50
+ className={cn(tabsListVariants({ variant }), className)}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
57
+ return (
58
+ <TabsPrimitive.Tab
59
+ data-slot="tabs-trigger"
60
+ className={cn(
61
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground-secondary focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:outline-1 focus-visible:outline-primary/40 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
62
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:border-transparent",
63
+ "data-active:bg-background data-active:text-foreground data-active:border-border",
64
+ "after:absolute after:bg-primary after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
65
+ className
66
+ )}
67
+ {...props}
68
+ />
69
+ )
70
+ }
71
+
72
+ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
73
+ return (
74
+ <TabsPrimitive.Panel
75
+ data-slot="tabs-content"
76
+ className={cn("flex-1 text-sm outline-none", className)}
77
+ {...props}
78
+ />
79
+ )
80
+ }
81
+
82
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,18 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../lib/cn"
4
+
5
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
+ return (
7
+ <textarea
8
+ data-slot="textarea"
9
+ className={cn(
10
+ "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ export { Textarea }