@windforge/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.
Files changed (61) hide show
  1. package/dist/index.d.ts +1195 -0
  2. package/dist/index.js +3628 -0
  3. package/package.json +66 -0
  4. package/src/catalog.ts +654 -0
  5. package/src/components/accordion.tsx +91 -0
  6. package/src/components/alert.tsx +58 -0
  7. package/src/components/autocomplete.tsx +174 -0
  8. package/src/components/avatar.tsx +60 -0
  9. package/src/components/badge.tsx +37 -0
  10. package/src/components/breadcrumb.tsx +62 -0
  11. package/src/components/button-group.tsx +23 -0
  12. package/src/components/button.tsx +53 -0
  13. package/src/components/calendar.tsx +61 -0
  14. package/src/components/card.tsx +72 -0
  15. package/src/components/chart.tsx +130 -0
  16. package/src/components/checkbox.tsx +27 -0
  17. package/src/components/chip.tsx +75 -0
  18. package/src/components/code-block.tsx +126 -0
  19. package/src/components/command.tsx +139 -0
  20. package/src/components/data-table.tsx +194 -0
  21. package/src/components/date-picker.tsx +77 -0
  22. package/src/components/dialog.tsx +57 -0
  23. package/src/components/dropdown-menu.tsx +186 -0
  24. package/src/components/form-field.tsx +97 -0
  25. package/src/components/input.tsx +29 -0
  26. package/src/components/label.tsx +18 -0
  27. package/src/components/layout.tsx +179 -0
  28. package/src/components/link.tsx +37 -0
  29. package/src/components/modal.tsx +67 -0
  30. package/src/components/multi-select.tsx +175 -0
  31. package/src/components/pagination.tsx +72 -0
  32. package/src/components/popover.tsx +25 -0
  33. package/src/components/progress.tsx +31 -0
  34. package/src/components/radio-group.tsx +34 -0
  35. package/src/components/select.tsx +134 -0
  36. package/src/components/separator.tsx +21 -0
  37. package/src/components/sheet.tsx +80 -0
  38. package/src/components/skeleton.tsx +11 -0
  39. package/src/components/slider.tsx +28 -0
  40. package/src/components/stepper.tsx +69 -0
  41. package/src/components/switch.tsx +33 -0
  42. package/src/components/table.tsx +121 -0
  43. package/src/components/tabs.tsx +90 -0
  44. package/src/components/text.tsx +109 -0
  45. package/src/components/textarea.tsx +27 -0
  46. package/src/components/toast.tsx +107 -0
  47. package/src/components/toggle-button.tsx +103 -0
  48. package/src/components/tooltip.tsx +26 -0
  49. package/src/icons/forge-icon.tsx +55 -0
  50. package/src/icons/icon-set.ts +60 -0
  51. package/src/icons/svg-icon.tsx +43 -0
  52. package/src/index.ts +80 -0
  53. package/src/layouts/app-bar.tsx +95 -0
  54. package/src/layouts/app-shell.tsx +80 -0
  55. package/src/layouts/side-nav.tsx +196 -0
  56. package/src/layouts/theme-provider.tsx +128 -0
  57. package/src/lib/recipes.ts +50 -0
  58. package/src/lib/types.ts +3 -0
  59. package/src/lib/use-media-query.ts +18 -0
  60. package/src/lib/utils.ts +10 -0
  61. package/tailwind-preset.cjs +77 -0
@@ -0,0 +1,95 @@
1
+ import * as React from 'react'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import { Menu, Sun, Moon, MonitorSmartphone } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import { Button } from '../components/button'
6
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../components/tooltip'
7
+ import { useTheme, type ColorMode } from './theme-provider'
8
+ import { useAppShell } from './app-shell'
9
+
10
+ // `variant` picks the surface treatment: `glass` is the translucent, blurred bar
11
+ // (content scrolls frostily behind it — the default); `solid` is a flat, opaque
12
+ // bar for a calmer, cleaner chrome. `color` overrides which surface token the bar
13
+ // sits on (e.g. `inset`) under either treatment. Class strings are spelled out in
14
+ // full so Tailwind's content scanner keeps them.
15
+ const appBarVariants = cva(
16
+ 'sticky top-0 z-30 flex h-16 items-center gap-2 border-b border-border px-4',
17
+ {
18
+ variants: {
19
+ variant: { glass: 'backdrop-blur-md', solid: '' },
20
+ color: { surface: '', subtle: '', inset: '' },
21
+ },
22
+ compoundVariants: [
23
+ { variant: 'glass', color: 'surface', class: 'bg-surface/80' },
24
+ { variant: 'glass', color: 'subtle', class: 'bg-surface-subtle/80' },
25
+ { variant: 'glass', color: 'inset', class: 'bg-surface-inset/80' },
26
+ { variant: 'solid', color: 'surface', class: 'bg-surface' },
27
+ { variant: 'solid', color: 'subtle', class: 'bg-surface-subtle' },
28
+ { variant: 'solid', color: 'inset', class: 'bg-surface-inset' },
29
+ ],
30
+ defaultVariants: { variant: 'glass', color: 'surface' },
31
+ },
32
+ )
33
+
34
+ export interface AppBarProps extends VariantProps<typeof appBarVariants> {
35
+ title?: string
36
+ logo?: React.ReactNode
37
+ actions?: React.ReactNode
38
+ /** Override the menu-button handler. Inside an AppShell it defaults to toggling the nav. */
39
+ onMenuClick?: () => void
40
+ }
41
+
42
+ const modeIcon: Record<ColorMode, React.ReactNode> = {
43
+ light: <Sun className="h-5 w-5" />,
44
+ dark: <Moon className="h-5 w-5" />,
45
+ system: <MonitorSmartphone className="h-5 w-5" />,
46
+ }
47
+ const modeLabel: Record<ColorMode, string> = {
48
+ light: 'Light — switch to dark',
49
+ dark: 'Dark — switch to system',
50
+ system: 'System — switch to light',
51
+ }
52
+
53
+ /**
54
+ * ModeToggle — the light/dark/system switch, as a standalone control. Drop it
55
+ * into an AppBar's `actions` (or anywhere else); it reads and cycles the theme
56
+ * from the surrounding ThemeProvider.
57
+ */
58
+ export function ModeToggle() {
59
+ const { mode, cycleMode } = useTheme()
60
+ return (
61
+ <TooltipProvider>
62
+ <Tooltip>
63
+ <TooltipTrigger asChild>
64
+ <Button variant="tertiary" size="icon" onClick={cycleMode} aria-label={modeLabel[mode]}>
65
+ {modeIcon[mode]}
66
+ </Button>
67
+ </TooltipTrigger>
68
+ <TooltipContent>{modeLabel[mode]}</TooltipContent>
69
+ </Tooltip>
70
+ </TooltipProvider>
71
+ )
72
+ }
73
+
74
+ export function AppBar({
75
+ title, logo, actions, onMenuClick, variant, color,
76
+ }: AppBarProps) {
77
+ const appShell = useAppShell()
78
+ const handleMenu = onMenuClick ?? appShell?.toggleNav
79
+
80
+ return (
81
+ <header className={cn(appBarVariants({ variant, color }))}>
82
+ {handleMenu && (
83
+ <Button variant="tertiary" size="icon" onClick={handleMenu} aria-label="Toggle navigation">
84
+ <Menu className="h-5 w-5" />
85
+ </Button>
86
+ )}
87
+ {logo}
88
+ {title && <span className="text-sm font-semibold text-secondary">{title}</span>}
89
+
90
+ <div className="ml-auto flex items-center gap-1">
91
+ {actions}
92
+ </div>
93
+ </header>
94
+ )
95
+ }
@@ -0,0 +1,80 @@
1
+ import * as React from 'react'
2
+ import { Sheet, SheetContent } from '../components/sheet'
3
+ import { Box } from '../components/layout'
4
+ import { useMediaQuery } from '../lib/use-media-query'
5
+ import { cn } from '../lib/utils'
6
+
7
+ const NAV_WIDTH = 256
8
+
9
+ interface AppShellContextValue {
10
+ /** Toggle the sidebar — collapses it on desktop, opens/closes the drawer on mobile. */
11
+ toggleNav: () => void
12
+ /** Close the mobile drawer (no-op on desktop). */
13
+ closeNav: () => void
14
+ }
15
+ const AppShellContext = React.createContext<AppShellContextValue | null>(null)
16
+
17
+ /** Read the AppShell coordination handles. Returns null outside an AppShell. */
18
+ export const useAppShell = () => React.useContext(AppShellContext)
19
+
20
+ export interface AppShellProps {
21
+ /** The top bar slot — typically an `<AppBar>`. Rendered inside the shell so it
22
+ * can drive the nav via {@link useAppShell}. */
23
+ header?: React.ReactNode
24
+ /** The side navigation slot — typically a `<SideNav>`. */
25
+ sidebar?: React.ReactNode
26
+ children: React.ReactNode
27
+ fullBleed?: boolean
28
+ /** Whether the desktop sidebar starts expanded. Set `false` to open collapsed
29
+ * — e.g. a personal site where the nav is secondary. The menu button (and
30
+ * `useAppShell().toggleNav`) flips it. Defaults to `true`. */
31
+ defaultNavOpen?: boolean
32
+ }
33
+
34
+ /**
35
+ * AppShell — the guaranteed-layout chrome: a fixed sidebar on desktop, a Sheet
36
+ * drawer on mobile, a sticky bar, and a scrollable main. `header` and `sidebar`
37
+ * are slots (their own components, `AppBar` and `SideNav`); AppShell only owns
38
+ * the responsive frame and exposes toggle/close handles via {@link useAppShell}.
39
+ */
40
+ export function AppShell({ header, sidebar, children, fullBleed = false, defaultNavOpen = true }: AppShellProps) {
41
+ const isMobile = useMediaQuery('(max-width: 1023px)')
42
+ const [navOpen, setNavOpen] = React.useState(defaultNavOpen)
43
+ const [drawerOpen, setDrawerOpen] = React.useState(false)
44
+
45
+ const ctx = React.useMemo<AppShellContextValue>(
46
+ () => ({
47
+ toggleNav: () => (isMobile ? setDrawerOpen((o) => !o) : setNavOpen((o) => !o)),
48
+ closeNav: () => setDrawerOpen(false),
49
+ }),
50
+ [isMobile],
51
+ )
52
+
53
+ const desktopNavVisible = !isMobile && navOpen
54
+
55
+ return (
56
+ <AppShellContext.Provider value={ctx}>
57
+ <div className="flex min-h-screen bg-background text-primary">
58
+ {desktopNavVisible && sidebar && (
59
+ <aside className="fixed inset-y-0 left-0 z-20 shrink-0" style={{ width: NAV_WIDTH }}>
60
+ {sidebar}
61
+ </aside>
62
+ )}
63
+
64
+ <Sheet open={isMobile && drawerOpen} onOpenChange={setDrawerOpen}>
65
+ <SheetContent side="left">
66
+ <Box className="-m-6 h-[calc(100%+3rem)] w-64">{sidebar}</Box>
67
+ </SheetContent>
68
+ </Sheet>
69
+
70
+ <div
71
+ className="flex min-w-0 flex-1 flex-col transition-[margin] duration-normal"
72
+ style={{ marginLeft: desktopNavVisible ? NAV_WIDTH : 0 }}
73
+ >
74
+ {header}
75
+ <main className={cn('flex-1 overflow-auto', !fullBleed && 'px-gutter py-lg md:px-xl')}>{children}</main>
76
+ </div>
77
+ </div>
78
+ </AppShellContext.Provider>
79
+ )
80
+ }
@@ -0,0 +1,196 @@
1
+ import * as React from 'react'
2
+ import * as AccordionPrimitive from '@radix-ui/react-accordion'
3
+ import { ChevronDown } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import { focusRingInset } from '../lib/recipes'
6
+ import { useAppShell } from './app-shell'
7
+
8
+ // ─── Nav row primitives (private to SideNav) ────────────────────────────────────
9
+ // Every row here is a clickable nav target — that's a menu, not a list — so these
10
+ // minimal helpers live with their only consumer instead of being a public `List`.
11
+ const NavList = ({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) => (
12
+ <ul className={cn('flex flex-col', className)} {...props} />
13
+ )
14
+
15
+ const navButtonBase =
16
+ 'group relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-left text-sm font-medium ' +
17
+ 'transition-colors disabled:opacity-40 disabled:pointer-events-none ' +
18
+ '[&_svg]:size-[18px] [&_svg]:shrink-0 focus-visible:ring-inset ' + focusRingInset
19
+
20
+ interface NavButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
21
+ active?: boolean
22
+ }
23
+
24
+ const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
25
+ ({ className, active, ...props }, ref) => (
26
+ <button
27
+ ref={ref}
28
+ type="button"
29
+ className={cn(
30
+ navButtonBase,
31
+ active ? 'bg-surface-inset text-primary' : 'text-secondary hover:bg-surface-inset hover:text-primary',
32
+ className,
33
+ )}
34
+ {...props}
35
+ />
36
+ ),
37
+ )
38
+ NavButton.displayName = 'NavButton'
39
+
40
+ const NavSubheader = ({ className, ...props }: React.HTMLAttributes<HTMLLIElement>) => (
41
+ <li
42
+ className={cn(
43
+ 'mt-md px-3 pb-1.5 pt-1 text-sm font-semibold uppercase tracking-wider text-tertiary first:mt-none',
44
+ className,
45
+ )}
46
+ {...props}
47
+ />
48
+ )
49
+
50
+ // ─── Nav config types (compatible with the MUI Windforge shape) ─────────────────
51
+ export interface NavLeafItem {
52
+ type?: 'item'
53
+ label: string
54
+ path?: string
55
+ icon?: React.ReactNode
56
+ badge?: React.ReactNode
57
+ onClick?: () => void
58
+ disabled?: boolean
59
+ }
60
+ export interface NavGroupItem {
61
+ type: 'group'
62
+ label: string
63
+ icon?: React.ReactNode
64
+ children: NavItem[]
65
+ defaultOpen?: boolean
66
+ }
67
+ export interface NavSectionItem {
68
+ type: 'section'
69
+ label: string
70
+ items: NavItem[]
71
+ }
72
+ export interface NavDividerItem {
73
+ type: 'divider'
74
+ }
75
+ export type NavItem = NavLeafItem | NavGroupItem | NavSectionItem | NavDividerItem
76
+
77
+ export interface SideNavProps {
78
+ items: NavItem[]
79
+ activePath?: string
80
+ onNavigate?: (path: string) => void
81
+ logo?: React.ReactNode
82
+ }
83
+
84
+ // Indent nested rows by one spacing step per level, starting from the row's own
85
+ // left padding (px-3). The per-level unit is a token; depth 0 keeps the base.
86
+ const indent = (depth: number) => ({
87
+ paddingLeft: depth > 0 ? `calc(0.75rem + ${depth} * var(--wf-spacing-md))` : undefined,
88
+ })
89
+
90
+ function NavLeaf({
91
+ item, active, depth, onNavigate,
92
+ }: { item: NavLeafItem; active: boolean; depth: number; onNavigate: (path: string) => void }) {
93
+ return (
94
+ <li>
95
+ <NavButton
96
+ active={active}
97
+ disabled={item.disabled}
98
+ aria-current={active ? 'page' : undefined}
99
+ style={indent(depth)}
100
+ onClick={() => {
101
+ item.onClick?.()
102
+ if (item.path) onNavigate(item.path)
103
+ }}
104
+ >
105
+ {active && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-surface-inverse" />}
106
+ {item.icon}
107
+ <span className="truncate">{item.label}</span>
108
+ {item.badge && <span className="ml-auto">{item.badge}</span>}
109
+ </NavButton>
110
+ </li>
111
+ )
112
+ }
113
+
114
+ function NavGroup({
115
+ item, activePath, depth, onNavigate,
116
+ }: { item: NavGroupItem; activePath: string; depth: number; onNavigate: (path: string) => void }) {
117
+ const containsActive = React.useMemo(() => hasActivePath(item.children, activePath), [item.children, activePath])
118
+ const defaultValue = (item.defaultOpen ?? containsActive) ? 'group' : undefined
119
+
120
+ return (
121
+ <li>
122
+ <AccordionPrimitive.Root type="single" collapsible defaultValue={defaultValue}>
123
+ <AccordionPrimitive.Item value="group">
124
+ <AccordionPrimitive.Header className="flex">
125
+ <AccordionPrimitive.Trigger asChild>
126
+ <NavButton
127
+ style={indent(depth)}
128
+ className="data-[state=open]:[&_svg:last-of-type]:rotate-180"
129
+ >
130
+ {item.icon}
131
+ <span className="truncate">{item.label}</span>
132
+ <ChevronDown className="ml-auto !size-4 transition-transform duration-200" />
133
+ </NavButton>
134
+ </AccordionPrimitive.Trigger>
135
+ </AccordionPrimitive.Header>
136
+ <AccordionPrimitive.Content className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
137
+ <NavList>
138
+ {item.children.map((child, index) => (
139
+ <NavRenderer key={index} item={child} activePath={activePath} depth={depth + 1} onNavigate={onNavigate} />
140
+ ))}
141
+ </NavList>
142
+ </AccordionPrimitive.Content>
143
+ </AccordionPrimitive.Item>
144
+ </AccordionPrimitive.Root>
145
+ </li>
146
+ )
147
+ }
148
+
149
+ function NavRenderer({
150
+ item, activePath, depth, onNavigate,
151
+ }: { item: NavItem; activePath: string; depth: number; onNavigate: (path: string) => void }) {
152
+ if (item.type === 'divider') return <li role="separator" className="my-sm h-px bg-border" />
153
+ if (item.type === 'section') {
154
+ return (
155
+ <>
156
+ <NavSubheader>{item.label}</NavSubheader>
157
+ {item.items.map((child, index) => (
158
+ <NavRenderer key={index} item={child} activePath={activePath} depth={depth} onNavigate={onNavigate} />
159
+ ))}
160
+ </>
161
+ )
162
+ }
163
+ if (item.type === 'group') {
164
+ return <NavGroup item={item} activePath={activePath} depth={depth} onNavigate={onNavigate} />
165
+ }
166
+ return <NavLeaf item={item} active={!!item.path && activePath === item.path} depth={depth} onNavigate={onNavigate} />
167
+ }
168
+
169
+ function hasActivePath(items: NavItem[], activePath: string): boolean {
170
+ return items.some((item) => {
171
+ if (item.type === 'group') return hasActivePath(item.children, activePath)
172
+ if (item.type === 'section') return hasActivePath(item.items, activePath)
173
+ if (!item.type || item.type === 'item') return item.path === activePath
174
+ return false
175
+ })
176
+ }
177
+
178
+ export function SideNav({ items, activePath = '/', onNavigate = () => {}, logo }: SideNavProps) {
179
+ const appShell = useAppShell()
180
+ const handleNavigate = (path: string) => {
181
+ onNavigate(path)
182
+ appShell?.closeNav()
183
+ }
184
+ return (
185
+ <nav aria-label="Main navigation" className="flex h-full flex-col border-r border-border bg-surface-subtle">
186
+ {logo && (
187
+ <div className="flex h-16 shrink-0 items-center border-b border-border px-4">{logo}</div>
188
+ )}
189
+ <NavList className="flex-1 overflow-y-auto px-sm py-sm">
190
+ {items.map((item, index) => (
191
+ <NavRenderer key={index} item={item} activePath={activePath} depth={0} onNavigate={handleNavigate} />
192
+ ))}
193
+ </NavList>
194
+ </nav>
195
+ )
196
+ }
@@ -0,0 +1,128 @@
1
+ import * as React from 'react'
2
+
3
+ export type ColorMode = 'light' | 'dark' | 'system'
4
+ export type ResolvedMode = 'light' | 'dark'
5
+
6
+ const MODE_KEY = 'wf-mode'
7
+
8
+ /**
9
+ * Inline this in <head> BEFORE the app bundle to set the color mode on first
10
+ * paint and avoid a flash of the wrong theme (SSR/SPA alike). Mirrors the mode
11
+ * resolution below; reads the persisted `wf-mode`, falls back to system.
12
+ *
13
+ * <script dangerouslySetInnerHTML={{ __html: themeInitScript() }} />
14
+ */
15
+ export function themeInitScript(storageKey: string = MODE_KEY): string {
16
+ return `(function(){try{var m=localStorage.getItem('${storageKey}')||'system';var d=m==='dark'||(m==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var e=document.documentElement;e.classList.toggle('dark',d);e.style.colorScheme=d?'dark':'light';}catch(e){}})()`
17
+ }
18
+
19
+ const canUseDOM = typeof window !== 'undefined'
20
+ const readStoredMode = (): ColorMode | null => {
21
+ try {
22
+ return canUseDOM ? (localStorage.getItem(MODE_KEY) as ColorMode | null) : null
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ const prefersDark = () =>
29
+ canUseDOM && window.matchMedia('(prefers-color-scheme: dark)').matches
30
+ const resolveMode = (mode: ColorMode): ResolvedMode =>
31
+ mode === 'system' ? (prefersDark() ? 'dark' : 'light') : mode
32
+ const nextMode = (mode: ColorMode): ColorMode =>
33
+ mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
34
+
35
+ interface ThemeContextValue {
36
+ mode: ColorMode
37
+ resolvedMode: ResolvedMode
38
+ setMode: (mode: ColorMode) => void
39
+ cycleMode: () => void
40
+ }
41
+ const ThemeContext = React.createContext<ThemeContextValue | null>(null)
42
+
43
+ export interface ThemeProviderProps {
44
+ children: React.ReactNode
45
+ defaultMode?: ColorMode
46
+ /**
47
+ * Runtime token overrides — a `--wf-*` → value map applied as custom properties
48
+ * on the document root, so the whole app (including portaled overlays) re-skins
49
+ * with no rebuild. Pass a function to stay mode-aware:
50
+ * `tokens={(mode) => brandVars(ramp, mode)}`. For more than color, use
51
+ * `themeVars({ brand, fontSans, radius, … }, mode)`. For a site's fixed identity,
52
+ * prefer a static CSS `:root` override instead.
53
+ */
54
+ tokens?: Record<string, string> | ((mode: ResolvedMode) => Record<string, string>)
55
+ persist?: boolean
56
+ }
57
+
58
+ export function ThemeProvider({ children, defaultMode = 'system', tokens, persist = true }: ThemeProviderProps) {
59
+ const [mode, setModeState] = React.useState<ColorMode>(
60
+ () => (persist && readStoredMode()) || defaultMode,
61
+ )
62
+ const [resolvedMode, setResolvedMode] = React.useState<ResolvedMode>(() => resolveMode(mode))
63
+
64
+ const applyMode = (mode: ColorMode) => {
65
+ const resolved = resolveMode(mode)
66
+ setResolvedMode(resolved)
67
+ if (!canUseDOM) return
68
+ const root = document.documentElement
69
+
70
+ const noTransitions = document.createElement('style')
71
+ noTransitions.textContent = '*,*::before,*::after{transition:none!important}'
72
+ document.head.appendChild(noTransitions)
73
+
74
+ root.classList.toggle('dark', resolved === 'dark')
75
+ root.style.colorScheme = resolved
76
+
77
+ void root.offsetHeight
78
+ window.setTimeout(() => noTransitions.remove(), 1)
79
+ }
80
+
81
+ const setMode = (mode: ColorMode) => {
82
+ setModeState(mode)
83
+ if (persist) {
84
+ try {
85
+ localStorage.setItem(MODE_KEY, mode)
86
+ } catch {
87
+ /* storage unavailable (private mode / SSR) — mode still applies in memory */
88
+ }
89
+ }
90
+ applyMode(mode)
91
+ }
92
+
93
+ React.useEffect(() => {
94
+ if (mode !== 'system') return
95
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
96
+ const onSystemChange = () => applyMode('system')
97
+ mediaQuery.addEventListener('change', onSystemChange)
98
+ return () => mediaQuery.removeEventListener('change', onSystemChange)
99
+ }, [mode])
100
+
101
+ const value: ThemeContextValue = { mode, resolvedMode, setMode, cycleMode: () => setMode(nextMode(mode)) }
102
+ const resolvedTokens = typeof tokens === 'function' ? tokens(resolvedMode) : tokens
103
+ // Stable signature so the effect only re-applies when the values actually change.
104
+ const tokenSig = resolvedTokens ? JSON.stringify(resolvedTokens) : ''
105
+
106
+ // Apply runtime token overrides to the document ROOT (not an in-tree wrapper) so
107
+ // they reach portaled overlays too — Dialog/DropdownMenu/Tooltip/Select/Toast/Sheet
108
+ // render into document.body, outside any React subtree. Custom properties on
109
+ // :root cascade everywhere. We track and remove exactly the keys we set.
110
+ React.useLayoutEffect(() => {
111
+ if (!canUseDOM || !tokenSig) return
112
+ const map = JSON.parse(tokenSig) as Record<string, string>
113
+ const root = document.documentElement
114
+ const keys = Object.keys(map)
115
+ for (const key of keys) root.style.setProperty(key, map[key])
116
+ return () => {
117
+ for (const key of keys) root.style.removeProperty(key)
118
+ }
119
+ }, [tokenSig])
120
+
121
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
122
+ }
123
+
124
+ export function useTheme() {
125
+ const ctx = React.useContext(ThemeContext)
126
+ if (!ctx) throw new Error('useTheme must be used within a <ThemeProvider>')
127
+ return ctx
128
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * RECIPES — named class-fragment bundles shared across components.
3
+ *
4
+ * These collapse the utility groups that repeat verbatim across many primitives
5
+ * (focus rings, floating surfaces, menu rows, overlays) into one named class
6
+ * each, so a component reads as `cn(floatingPanel, '…specifics…')` instead of a
7
+ * wall of utilities. Compose them with `cn()` — because `cn` runs tailwind-merge,
8
+ * a component can override any piece simply by adding the conflicting utility
9
+ * after the recipe (e.g. `cn(floatingPanel, 'rounded-xl shadow-lg')`).
10
+ *
11
+ * Every utility here resolves to a token (`var(--wf-*)`), so these stay
12
+ * theme- and accent-aware: the class is stable, the values move underneath it.
13
+ */
14
+
15
+ /** Keyboard focus ring for standalone interactive controls (buttons, triggers, thumbs). */
16
+ export const focusRing =
17
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' +
18
+ 'focus-visible:ring-offset-2 focus-visible:ring-offset-background'
19
+
20
+ /** Focus ring with no offset — for controls that sit flush inside a container. */
21
+ export const focusRingInset =
22
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
23
+
24
+ /** Focus treatment for text fields — recolors the border and adds a ring. */
25
+ export const focusRingField =
26
+ 'focus-visible:outline-none focus-visible:border-focus focus-visible:ring-2 focus-visible:ring-ring'
27
+
28
+ /**
29
+ * Floating surface for popovers / menus / listboxes. Radius (`rounded-lg`) and
30
+ * shadow (`shadow-md`) are sensible defaults — override per component via `cn`.
31
+ * Padding, width, max-height and entrance animation are left to the caller.
32
+ */
33
+ export const floatingPanel =
34
+ 'z-50 rounded-lg border border-border bg-surface text-primary shadow-md outline-none'
35
+
36
+ /**
37
+ * Structural recipe for a selectable row inside a menu / listbox. Intentionally
38
+ * carries no focus colors or padding — those differ per surface (dropdown and
39
+ * select both use `focus:bg-surface-inset`; autocomplete highlights the same way).
40
+ */
41
+ export const menuItem =
42
+ 'relative flex cursor-pointer select-none items-center rounded-md text-sm outline-none ' +
43
+ 'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
44
+
45
+ /** Full-screen scrim behind modal surfaces (dialog, sheet). */
46
+ export const overlayBackdrop = 'fixed inset-0 z-50 bg-overlay animate-fade-in'
47
+
48
+ /** The corner dismiss (×) button shared by dialog and sheet. Pair with `focusRingInset`. */
49
+ export const dismissButton =
50
+ 'absolute right-4 top-4 rounded-md p-1 text-secondary opacity-70 transition-opacity hover:opacity-100'
@@ -0,0 +1,3 @@
1
+ /** Strip the styling escape hatch. Applied to every component except the
2
+ * layout/building-block primitives (Box, Stack, Grid). */
3
+ export type NoStyle<T> = Omit<T, 'className' | 'style'>
@@ -0,0 +1,18 @@
1
+ import * as React from 'react'
2
+
3
+ /** Subscribe to a media query; SSR-safe (returns false until mounted). */
4
+ export function useMediaQuery(query: string): boolean {
5
+ const subscribe = React.useCallback(
6
+ (onChange: () => void) => {
7
+ const mq = window.matchMedia(query)
8
+ mq.addEventListener('change', onChange)
9
+ return () => mq.removeEventListener('change', onChange)
10
+ },
11
+ [query],
12
+ )
13
+ return React.useSyncExternalStore(
14
+ subscribe,
15
+ () => window.matchMedia(query).matches,
16
+ () => false,
17
+ )
18
+ }
@@ -0,0 +1,10 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ /**
5
+ * `cn` — merge conditional class lists and resolve Tailwind conflicts.
6
+ * The one utility every component uses to compose token-driven classes.
7
+ */
8
+ export function cn(...inputs: ClassValue[]) {
9
+ return twMerge(clsx(inputs))
10
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @windforge/ui — the shippable Tailwind PRESET.
3
+ *
4
+ * This is the single thing a consumer needs to be guaranteed-on-system. It wires
5
+ * the generated token map into Tailwind's theme (colors, radius, shadow, spacing,
6
+ * type, icon sizes), supplies every keyframe/animation the components reference,
7
+ * and bundles the `tailwindcss-animate` plugin. Drop it into a project's
8
+ * `tailwind.config` and the components render correctly with zero hand-copied config:
9
+ *
10
+ * // tailwind.config.cjs
11
+ * module.exports = {
12
+ * presets: [require('@windforge/ui/tailwind')],
13
+ * content: [
14
+ * './src/**\/*.{ts,tsx}',
15
+ * './node_modules/@windforge/ui/src/**\/*.{ts,tsx}', // scan the lib's classes
16
+ * ],
17
+ * }
18
+ *
19
+ * The token VALUES are CSS variables, so a brand/font/radius swap happens by
20
+ * overriding `--wf-*` (see @windforge/tokens) — never by editing this preset.
21
+ */
22
+ const tokens = require('@windforge/tokens/tailwind')
23
+
24
+ /** @type {import('tailwindcss').Config} */
25
+ module.exports = {
26
+ darkMode: 'class',
27
+ theme: {
28
+ // Tokens fully REPLACE Tailwind's default palette/scales — the design system
29
+ // is the only source of truth. No raw Tailwind colors leak into components.
30
+ colors: {
31
+ transparent: 'transparent',
32
+ current: 'currentColor',
33
+ white: '#ffffff',
34
+ black: '#000000',
35
+ ...tokens.colors,
36
+ },
37
+ borderRadius: tokens.borderRadius,
38
+ boxShadow: { none: 'none', ...tokens.boxShadow },
39
+ fontFamily: tokens.fontFamily,
40
+ fontSize: tokens.fontSize,
41
+ // Flat border-color utilities (border-strong, border-subtle, border-focus, …).
42
+ borderColor: tokens.borderColor,
43
+ extend: {
44
+ spacing: tokens.spacing,
45
+ size: tokens.size,
46
+ borderWidth: tokens.borderWidth,
47
+ ringColor: { DEFAULT: 'var(--wf-color-border-focus)' },
48
+ keyframes: {
49
+ 'accordion-down': {
50
+ from: { height: '0' },
51
+ to: { height: 'var(--radix-accordion-content-height)' },
52
+ },
53
+ 'accordion-up': {
54
+ from: { height: 'var(--radix-accordion-content-height)' },
55
+ to: { height: '0' },
56
+ },
57
+ 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } },
58
+ 'scale-in': {
59
+ from: { opacity: '0', scale: '0.96' },
60
+ to: { opacity: '1', scale: '1' },
61
+ },
62
+ 'progress-indeterminate': {
63
+ '0%': { transform: 'translateX(-100%)' },
64
+ '100%': { transform: 'translateX(250%)' },
65
+ },
66
+ },
67
+ animation: {
68
+ 'accordion-down': 'accordion-down 200ms ease-out',
69
+ 'accordion-up': 'accordion-up 200ms ease-out',
70
+ 'fade-in': 'fade-in 160ms ease-out',
71
+ 'scale-in': 'scale-in 160ms cubic-bezier(0.175,0.885,0.32,1.275)',
72
+ 'progress-indeterminate': 'progress-indeterminate 1.4s ease-in-out infinite',
73
+ },
74
+ },
75
+ },
76
+ plugins: [require('tailwindcss-animate')],
77
+ }