vinta-schedule-design-system 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 (107) hide show
  1. package/components.json +21 -0
  2. package/dist/styles.css +3452 -0
  3. package/package.json +103 -0
  4. package/src/layout/app-shell.tsx +115 -0
  5. package/src/layout/app-topbar.stories.tsx +74 -0
  6. package/src/layout/app-topbar.tsx +145 -0
  7. package/src/layout/auth-layout.tsx +45 -0
  8. package/src/layout/box.stories.tsx +86 -0
  9. package/src/layout/box.tsx +34 -0
  10. package/src/layout/center.tsx +24 -0
  11. package/src/layout/container.stories.tsx +44 -0
  12. package/src/layout/container.tsx +39 -0
  13. package/src/layout/divider.tsx +64 -0
  14. package/src/layout/flex.stories.tsx +109 -0
  15. package/src/layout/flex.test.tsx +71 -0
  16. package/src/layout/flex.tsx +112 -0
  17. package/src/layout/grid.stories.tsx +91 -0
  18. package/src/layout/grid.tsx +132 -0
  19. package/src/layout/heading.tsx +53 -0
  20. package/src/layout/index.ts +47 -0
  21. package/src/layout/layout-style.ts +291 -0
  22. package/src/layout/link.stories.tsx +54 -0
  23. package/src/layout/link.test.tsx +75 -0
  24. package/src/layout/link.tsx +100 -0
  25. package/src/layout/navbar.stories.tsx +70 -0
  26. package/src/layout/navbar.tsx +128 -0
  27. package/src/layout/page-header.stories.tsx +80 -0
  28. package/src/layout/page-header.tsx +59 -0
  29. package/src/layout/section.tsx +22 -0
  30. package/src/layout/spacer.tsx +21 -0
  31. package/src/layout/stack.stories.tsx +58 -0
  32. package/src/layout/stack.tsx +19 -0
  33. package/src/layout/text.tsx +139 -0
  34. package/src/layout/typography.stories.tsx +112 -0
  35. package/src/lib/utils.ts +6 -0
  36. package/src/story-types.ts +80 -0
  37. package/src/styles/build.css +33 -0
  38. package/src/styles/globals.css +9 -0
  39. package/src/styles/tokens.css +281 -0
  40. package/src/ui/accordion.stories.tsx +84 -0
  41. package/src/ui/accordion.tsx +57 -0
  42. package/src/ui/alert-dialog.stories.tsx +57 -0
  43. package/src/ui/alert-dialog.tsx +141 -0
  44. package/src/ui/alert.stories.tsx +46 -0
  45. package/src/ui/alert.tsx +59 -0
  46. package/src/ui/avatar.stories.tsx +56 -0
  47. package/src/ui/avatar.tsx +50 -0
  48. package/src/ui/badge.stories.tsx +105 -0
  49. package/src/ui/badge.tsx +57 -0
  50. package/src/ui/breadcrumb.stories.tsx +69 -0
  51. package/src/ui/breadcrumb.tsx +140 -0
  52. package/src/ui/button.stories.tsx +85 -0
  53. package/src/ui/button.tsx +60 -0
  54. package/src/ui/card.stories.tsx +62 -0
  55. package/src/ui/card.tsx +96 -0
  56. package/src/ui/carousel.stories.tsx +55 -0
  57. package/src/ui/carousel.tsx +262 -0
  58. package/src/ui/checkbox.stories.tsx +52 -0
  59. package/src/ui/checkbox.tsx +30 -0
  60. package/src/ui/combobox.stories.tsx +138 -0
  61. package/src/ui/combobox.tsx +217 -0
  62. package/src/ui/command.tsx +184 -0
  63. package/src/ui/dialog.stories.tsx +66 -0
  64. package/src/ui/dialog.tsx +163 -0
  65. package/src/ui/drawer.stories.tsx +80 -0
  66. package/src/ui/drawer.tsx +118 -0
  67. package/src/ui/dropdown-menu.stories.tsx +57 -0
  68. package/src/ui/dropdown-menu.tsx +201 -0
  69. package/src/ui/form.stories.tsx +70 -0
  70. package/src/ui/form.tsx +181 -0
  71. package/src/ui/input-otp.stories.tsx +52 -0
  72. package/src/ui/input-otp.tsx +71 -0
  73. package/src/ui/input.stories.tsx +68 -0
  74. package/src/ui/input.tsx +22 -0
  75. package/src/ui/label.stories.tsx +35 -0
  76. package/src/ui/label.tsx +26 -0
  77. package/src/ui/navigation-menu.stories.tsx +79 -0
  78. package/src/ui/navigation-menu.tsx +128 -0
  79. package/src/ui/pagination.stories.tsx +62 -0
  80. package/src/ui/pagination.tsx +137 -0
  81. package/src/ui/popover.tsx +89 -0
  82. package/src/ui/progress.stories.tsx +33 -0
  83. package/src/ui/progress.tsx +28 -0
  84. package/src/ui/radio-group.stories.tsx +52 -0
  85. package/src/ui/radio-group.tsx +44 -0
  86. package/src/ui/scheduling-chip.stories.tsx +117 -0
  87. package/src/ui/scheduling-chip.tsx +62 -0
  88. package/src/ui/scroll-area.stories.tsx +52 -0
  89. package/src/ui/scroll-area.tsx +48 -0
  90. package/src/ui/select.stories.tsx +56 -0
  91. package/src/ui/select.tsx +159 -0
  92. package/src/ui/sheet.stories.tsx +63 -0
  93. package/src/ui/sheet.tsx +141 -0
  94. package/src/ui/skeleton.stories.tsx +40 -0
  95. package/src/ui/skeleton.tsx +15 -0
  96. package/src/ui/sonner.stories.tsx +62 -0
  97. package/src/ui/sonner.tsx +34 -0
  98. package/src/ui/switch.stories.tsx +41 -0
  99. package/src/ui/switch.tsx +29 -0
  100. package/src/ui/table.stories.tsx +88 -0
  101. package/src/ui/table.tsx +132 -0
  102. package/src/ui/tabs.stories.tsx +63 -0
  103. package/src/ui/tabs.tsx +55 -0
  104. package/src/ui/textarea.stories.tsx +45 -0
  105. package/src/ui/textarea.tsx +22 -0
  106. package/src/ui/tooltip.stories.tsx +48 -0
  107. package/src/ui/tooltip.tsx +32 -0
package/package.json ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "name": "vinta-schedule-design-system",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "sideEffects": [
7
+ "**/*.css"
8
+ ],
9
+ "exports": {
10
+ "./ui/*": "./src/ui/*.tsx",
11
+ "./layout": "./src/layout/index.ts",
12
+ "./layout/*": "./src/layout/*.tsx",
13
+ "./lib/utils": "./src/lib/utils.ts",
14
+ "./story-types": "./src/story-types.ts",
15
+ "./styles.css": "./dist/styles.css",
16
+ "./styles/tokens.css": "./src/styles/tokens.css",
17
+ "./styles/globals.css": "./src/styles/globals.css"
18
+ },
19
+ "files": [
20
+ "src/ui",
21
+ "src/layout",
22
+ "src/lib",
23
+ "src/styles",
24
+ "src/story-types.ts",
25
+ "dist",
26
+ "components.json"
27
+ ],
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "storybook": "storybook dev -p 6007",
33
+ "build-storybook": "storybook build",
34
+ "build:css": "tailwindcss -i ./src/styles/build.css -o ./dist/styles.css",
35
+ "build": "pnpm run build:css",
36
+ "check:contract": "node ./scripts/check-contract.mjs",
37
+ "prepack": "pnpm run build:css"
38
+ },
39
+ "peerDependencies": {
40
+ "react": "^19.0.0",
41
+ "react-dom": "^19.0.0",
42
+ "vinta-ui-composer-prototype-mode": "^0.1.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "vinta-ui-composer-prototype-mode": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "dependencies": {
50
+ "@radix-ui/react-accordion": "^1.2.12",
51
+ "@radix-ui/react-alert-dialog": "^1.1.15",
52
+ "@radix-ui/react-avatar": "^1.1.11",
53
+ "@radix-ui/react-checkbox": "^1.3.3",
54
+ "@radix-ui/react-dialog": "^1.1.15",
55
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
56
+ "@radix-ui/react-label": "^2.1.8",
57
+ "@radix-ui/react-navigation-menu": "^1.2.14",
58
+ "@radix-ui/react-progress": "^1.1.8",
59
+ "@radix-ui/react-radio-group": "^1.3.8",
60
+ "@radix-ui/react-scroll-area": "^1.2.10",
61
+ "@radix-ui/react-select": "^2.2.6",
62
+ "@radix-ui/react-slot": "^1.2.4",
63
+ "@radix-ui/react-switch": "^1.2.6",
64
+ "@radix-ui/react-tabs": "^1.1.13",
65
+ "@radix-ui/react-tooltip": "^1.2.8",
66
+ "class-variance-authority": "^0.7.1",
67
+ "clsx": "^2.1.1",
68
+ "cmdk": "^1.1.1",
69
+ "embla-carousel-react": "^8.6.0",
70
+ "input-otp": "^1.4.2",
71
+ "lucide-react": "^1.17.0",
72
+ "next-themes": "^0.4.6",
73
+ "radix-ui": "^1.5.0",
74
+ "react-hook-form": "^7.77.0",
75
+ "sonner": "^2.0.7",
76
+ "tailwind-merge": "^3.6.0",
77
+ "vaul": "^1.1.2"
78
+ },
79
+ "devDependencies": {
80
+ "@storybook/addon-a11y": "^10.4.2",
81
+ "@storybook/addon-docs": "^10.4.2",
82
+ "@storybook/addon-themes": "^10.4.2",
83
+ "@storybook/react-vite": "^10.4.2",
84
+ "@tailwindcss/cli": "^4.3.0",
85
+ "@tailwindcss/postcss": "^4.3.0",
86
+ "@testing-library/jest-dom": "^6.9.1",
87
+ "@testing-library/react": "^16.3.2",
88
+ "@testing-library/user-event": "^14.6.1",
89
+ "@types/react": "^19.2.16",
90
+ "@types/react-dom": "^19.2.3",
91
+ "@vitejs/plugin-react": "^6.0.2",
92
+ "esbuild": "^0.28.1",
93
+ "jsdom": "^29.1.1",
94
+ "react": "^19.2.7",
95
+ "react-dom": "^19.2.7",
96
+ "storybook": "^10.4.2",
97
+ "tailwindcss": "^4.3.0",
98
+ "tw-animate-css": "^1.4.0",
99
+ "typescript": "^6.0.3",
100
+ "vinta-ui-composer-prototype-mode": "^0.1.0",
101
+ "vitest": "^4.1.8"
102
+ }
103
+ }
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Menu } from 'lucide-react';
5
+
6
+ import { cn } from '../lib/utils';
7
+ import { Button } from '../ui/button';
8
+ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '../ui/sheet';
9
+ import { Box } from './box';
10
+ import { Flex } from './flex';
11
+ import { Container } from './container';
12
+
13
+ /**
14
+ * AppShell — the authenticated application frame. Composes an optional sidebar
15
+ * and topbar around a scrollable main region. `width` controls whether the page
16
+ * body is capped to the contained 1200px column or runs full-bleed.
17
+ *
18
+ * Responsive: the shell is an `@container/app`. Above @4xl (896px) the sidebar
19
+ * is docked on the left; below that it collapses into a slide-over drawer
20
+ * toggled by a hamburger (injected into the topbar's `leading` slot, or shown
21
+ * in a thin mobile bar when there's no topbar). The page body is an
22
+ * `@container/content` so content reacts to its own width, not the viewport.
23
+ *
24
+ * Covers every generic page layout by toggling props:
25
+ * sidebar + topbar · topbar only · no bars × contained | full
26
+ */
27
+ export interface AppShellProps {
28
+ sidebar?: React.ReactNode;
29
+ topbar?: React.ReactNode;
30
+ width?: 'contained' | 'full';
31
+ className?: string;
32
+ /** Extra classes on the <Container> wrapping page content. */
33
+ contentClassName?: string;
34
+ children?: React.ReactNode;
35
+ }
36
+
37
+ function AppShell({
38
+ sidebar,
39
+ topbar,
40
+ width = 'contained',
41
+ className,
42
+ contentClassName,
43
+ children,
44
+ }: AppShellProps) {
45
+ const trigger = sidebar ? (
46
+ <SheetTrigger asChild>
47
+ <Button
48
+ variant='ghost'
49
+ size='icon'
50
+ aria-label='Open navigation'
51
+ className='-ml-1 shrink-0 @4xl/app:hidden'
52
+ >
53
+ <Menu />
54
+ </Button>
55
+ </SheetTrigger>
56
+ ) : null;
57
+
58
+ // Inject the mobile trigger into the topbar's leading slot when possible so
59
+ // the hamburger sits inline with the page title.
60
+ const topbarNode =
61
+ topbar && trigger && React.isValidElement(topbar)
62
+ ? React.cloneElement(
63
+ topbar as React.ReactElement<{ leading?: React.ReactNode }>,
64
+ { leading: trigger }
65
+ )
66
+ : topbar;
67
+
68
+ const shell = (
69
+ <Flex
70
+ height='screen'
71
+ width='full'
72
+ overflow='hidden'
73
+ bg='background'
74
+ color='foreground'
75
+ className={cn('@container/app', className)}
76
+ >
77
+ {/* Docked sidebar — display:contents above @4xl so the aside is a direct
78
+ flex child; fully hidden below (the drawer takes over). */}
79
+ {sidebar ? (
80
+ <div className='hidden @4xl/app:contents'>{sidebar}</div>
81
+ ) : null}
82
+
83
+ <Flex direction='column' grow={1} minWidth={0} overflow='hidden'>
84
+ {topbarNode}
85
+
86
+ {/* Mobile bar with just the trigger, for the no-topbar layouts. */}
87
+ {sidebar && !topbar ? (
88
+ <div className='border-border flex h-14 shrink-0 items-center border-b px-4 @4xl/app:hidden'>
89
+ {trigger}
90
+ </div>
91
+ ) : null}
92
+
93
+ <Box as='main' grow={1} overflow='auto' className='@container/content'>
94
+ <Container width={width} py={8} className={contentClassName}>
95
+ {children}
96
+ </Container>
97
+ </Box>
98
+ </Flex>
99
+ </Flex>
100
+ );
101
+
102
+ if (!sidebar) return shell;
103
+
104
+ return (
105
+ <Sheet>
106
+ {shell}
107
+ <SheetContent side='left' className='w-[244px] p-0'>
108
+ <SheetTitle className='sr-only'>Navigation</SheetTitle>
109
+ {sidebar}
110
+ </SheetContent>
111
+ </Sheet>
112
+ );
113
+ }
114
+
115
+ export { AppShell };
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from '../story-types';
2
+ import { CalendarPlus } from 'lucide-react';
3
+
4
+ import { AppTopbar } from './app-topbar';
5
+ import { Button } from '../ui/button';
6
+
7
+ const meta = {
8
+ title: 'Composition/AppTopbar',
9
+ component: AppTopbar,
10
+ tags: ['autodocs'],
11
+ // `title` / `subtitle` are ReactNode but render as plain copy → text controls.
12
+ // `actions` and `leading` are composed content → slots (§3). `className` /
13
+ // `style` stay unexposed (§6).
14
+ argTypes: {
15
+ title: { control: 'text', description: 'Page title' },
16
+ subtitle: {
17
+ control: 'text',
18
+ description: 'Secondary line under the title',
19
+ },
20
+ showSearch: { control: 'boolean' },
21
+ searchPlaceholder: { control: 'text' },
22
+ sync: {
23
+ control: 'select',
24
+ options: ['synced', 'syncing'],
25
+ description: 'Sync-status pill (omit to hide it)',
26
+ },
27
+ },
28
+ args: { title: 'Calendar', subtitle: 'This week · 3 bookings' },
29
+ parameters: {
30
+ layout: 'fullscreen',
31
+ puck: { slots: ['actions', 'leading'] },
32
+ },
33
+ } satisfies Meta<typeof AppTopbar>;
34
+
35
+ export default meta;
36
+ type Story = StoryObj<typeof meta>;
37
+
38
+ // Collapses to an icon button at narrow topbar widths.
39
+ const NewBookingAction = () => (
40
+ <>
41
+ <Button
42
+ size='icon'
43
+ aria-label='New booking'
44
+ className='shrink-0 @3xl/topbar:hidden'
45
+ >
46
+ <CalendarPlus />
47
+ </Button>
48
+ <Button size='sm' className='hidden shrink-0 @3xl/topbar:inline-flex'>
49
+ <CalendarPlus />
50
+ New booking
51
+ </Button>
52
+ </>
53
+ );
54
+
55
+ export const Default: Story = {
56
+ render: (args) => <AppTopbar {...args} actions={<NewBookingAction />} />,
57
+ };
58
+
59
+ export const Syncing: Story = {
60
+ args: { sync: 'syncing', subtitle: 'Refreshing connected calendars' },
61
+ render: (args) => <AppTopbar {...args} />,
62
+ };
63
+
64
+ export const NoSearch: Story = {
65
+ args: { showSearch: false, sync: null },
66
+ render: (args) => <AppTopbar {...args} />,
67
+ };
68
+
69
+ // At narrow widths: search → icon button, action → icon button, sync pill
70
+ // stays. Title shrinks.
71
+ export const Mobile: Story = {
72
+ globals: { viewport: { value: 'mobile' } },
73
+ render: (args) => <AppTopbar {...args} actions={<NewBookingAction />} />,
74
+ };
@@ -0,0 +1,145 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Check, RefreshCw, Search, X } from 'lucide-react';
5
+
6
+ import { cn } from '../lib/utils';
7
+ import { Badge } from '../ui/badge';
8
+ import { Button } from '../ui/button';
9
+ import { Input } from '../ui/input';
10
+
11
+ type SyncState = 'synced' | 'syncing';
12
+
13
+ /**
14
+ * AppTopbar — in-app header bar. Page title + subtitle on the left, optional
15
+ * search, a sync-status pill, and action slot on the right. Sticky + blurred.
16
+ */
17
+ export interface AppTopbarProps extends Omit<
18
+ React.HTMLAttributes<HTMLElement>,
19
+ 'title'
20
+ > {
21
+ /** Optional — omit for a minimal bar (e.g. just a leading trigger + actions). */
22
+ title?: React.ReactNode;
23
+ subtitle?: React.ReactNode;
24
+ actions?: React.ReactNode;
25
+ /** Leading slot — e.g. a mobile nav trigger injected by AppShell. */
26
+ leading?: React.ReactNode;
27
+ showSearch?: boolean;
28
+ searchPlaceholder?: string;
29
+ sync?: SyncState | null;
30
+ }
31
+
32
+ const AppTopbar = React.forwardRef<HTMLElement, AppTopbarProps>(
33
+ function AppTopbar(
34
+ {
35
+ className,
36
+ title,
37
+ subtitle,
38
+ actions,
39
+ leading,
40
+ showSearch = true,
41
+ searchPlaceholder = 'Search people & resources',
42
+ sync = 'synced',
43
+ ...props
44
+ },
45
+ ref
46
+ ) {
47
+ const [searchOpen, setSearchOpen] = React.useState(false);
48
+ return (
49
+ <header
50
+ ref={ref}
51
+ className={cn(
52
+ // @container/topbar so the inner controls react to the bar's own
53
+ // width, not the viewport — they collapse first when a sidebar eats
54
+ // the horizontal space. `relative` anchors the search overlay.
55
+ 'border-border bg-background/80 @container/topbar relative sticky top-0 z-20 flex h-16 shrink-0 items-center gap-3 border-b px-4 backdrop-blur @2xl/topbar:gap-4 @2xl/topbar:px-6',
56
+ className
57
+ )}
58
+ {...props}
59
+ >
60
+ {leading}
61
+
62
+ {title || subtitle ? (
63
+ <div className='min-w-0'>
64
+ {title ? (
65
+ <h1 className='truncate text-[15px] leading-tight font-semibold @2xl/topbar:text-[17px]'>
66
+ {title}
67
+ </h1>
68
+ ) : null}
69
+ {subtitle ? (
70
+ <p className='text-muted-foreground truncate text-[12.5px] leading-tight'>
71
+ {subtitle}
72
+ </p>
73
+ ) : null}
74
+ </div>
75
+ ) : null}
76
+
77
+ <div className='flex-1' />
78
+
79
+ {showSearch ? (
80
+ <>
81
+ {/* Narrow: collapse the field to an icon button that expands the
82
+ full-width search overlay. */}
83
+ <Button
84
+ variant='ghost'
85
+ size='icon'
86
+ aria-label={searchPlaceholder}
87
+ onClick={() => setSearchOpen(true)}
88
+ className='shrink-0 @3xl/topbar:hidden'
89
+ >
90
+ <Search />
91
+ </Button>
92
+ <div className='relative hidden w-64 @3xl/topbar:block'>
93
+ <Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2' />
94
+ <Input className='pl-9' placeholder={searchPlaceholder} />
95
+ </div>
96
+ </>
97
+ ) : null}
98
+
99
+ {sync ? (
100
+ <Badge
101
+ variant={sync === 'synced' ? 'success' : 'info'}
102
+ className='shrink-0'
103
+ >
104
+ {sync === 'synced' ? (
105
+ <Check />
106
+ ) : (
107
+ <RefreshCw className='animate-spin' />
108
+ )}
109
+ <span className='hidden @xl/topbar:inline'>
110
+ {sync === 'synced' ? 'All synced' : 'Syncing…'}
111
+ </span>
112
+ </Badge>
113
+ ) : null}
114
+
115
+ {actions}
116
+
117
+ {/* Expanded search — full-width, covers the bar. Autofocused; X closes. */}
118
+ {showSearch && searchOpen ? (
119
+ <div className='bg-background absolute inset-0 z-10 flex items-center gap-2 px-4 @2xl/topbar:px-6'>
120
+ <Search className='text-muted-foreground size-4 shrink-0' />
121
+ <Input
122
+ autoFocus
123
+ placeholder={searchPlaceholder}
124
+ onKeyDown={(e) => {
125
+ if (e.key === 'Escape') setSearchOpen(false);
126
+ }}
127
+ className='h-9 flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0'
128
+ />
129
+ <Button
130
+ variant='ghost'
131
+ size='icon'
132
+ aria-label='Close search'
133
+ onClick={() => setSearchOpen(false)}
134
+ className='shrink-0'
135
+ >
136
+ <X />
137
+ </Button>
138
+ </div>
139
+ ) : null}
140
+ </header>
141
+ );
142
+ }
143
+ );
144
+
145
+ export { AppTopbar };
@@ -0,0 +1,45 @@
1
+ import * as React from 'react';
2
+
3
+ import { Box } from './box';
4
+ import { Flex } from './flex';
5
+ import { Center } from './center';
6
+
7
+ /**
8
+ * AuthLayout — public authentication page shell: a top navbar above a single
9
+ * centered column. `variant` only changes how wide that column is:
10
+ * single → 448px (one-column forms)
11
+ * two-column → 768px (a double-width container that splits into two columns
12
+ * internally — e.g. social sign-in beside the form).
13
+ */
14
+ export interface AuthLayoutProps {
15
+ navbar?: React.ReactNode;
16
+ variant?: 'single' | 'two-column';
17
+ className?: string;
18
+ children?: React.ReactNode;
19
+ }
20
+
21
+ function AuthLayout({
22
+ navbar,
23
+ variant = 'single',
24
+ className,
25
+ children,
26
+ }: AuthLayoutProps) {
27
+ return (
28
+ <Flex
29
+ direction='column'
30
+ minHeight='screen'
31
+ bg='background'
32
+ color='foreground'
33
+ className={className}
34
+ >
35
+ {navbar}
36
+ <Center as='main' grow={1} px={4} py={12}>
37
+ <Box width='full' maxWidth={variant === 'two-column' ? 768 : 448}>
38
+ {children}
39
+ </Box>
40
+ </Center>
41
+ </Flex>
42
+ );
43
+ }
44
+
45
+ export { AuthLayout };
@@ -0,0 +1,86 @@
1
+ import type { Meta, StoryObj } from '../story-types';
2
+
3
+ import { Box } from './box';
4
+
5
+ const meta = {
6
+ title: 'Layout/Box',
7
+ component: Box,
8
+ tags: ['autodocs'],
9
+ // Box's props are the shared BoxStyleProps vocabulary (layout-style.ts). Only
10
+ // a meaningful subset is exposed — spacing, surface, sizing. `className` /
11
+ // `style` are deliberately NOT editable (§6), and `children` is the composed
12
+ // content slot (§3) so it must not appear in argTypes (SLOT_ARGTYPE_COLLISION).
13
+ argTypes: {
14
+ p: { control: 'select', options: [0, 2, 4, 6, 8, 12] },
15
+ px: { control: 'select', options: [0, 2, 4, 6, 8, 12] },
16
+ py: { control: 'select', options: [0, 2, 4, 6, 8, 12] },
17
+ radius: {
18
+ control: 'select',
19
+ options: ['none', 'sm', 'md', 'lg', 'xl', '2xl', 'full'],
20
+ },
21
+ shadow: {
22
+ control: 'select',
23
+ options: ['none', 'xs', 'sm', 'md', 'lg', 'xl'],
24
+ },
25
+ bg: {
26
+ control: 'select',
27
+ options: ['background', 'card', 'muted', 'accent', 'primary'],
28
+ },
29
+ color: {
30
+ control: 'select',
31
+ options: [
32
+ 'foreground',
33
+ 'muted-foreground',
34
+ 'primary',
35
+ 'primary-foreground',
36
+ 'vinta-700',
37
+ ],
38
+ },
39
+ border: { control: 'boolean' },
40
+ width: { control: 'select', options: [180, 320, 480, 'full'] },
41
+ textAlign: {
42
+ control: 'inline-radio',
43
+ options: ['left', 'center', 'right'],
44
+ },
45
+ },
46
+ args: {
47
+ p: 6,
48
+ bg: 'card',
49
+ radius: 'lg',
50
+ shadow: 'sm',
51
+ border: true,
52
+ },
53
+ parameters: { puck: { slots: ['children'] } },
54
+ } satisfies Meta<typeof Box>;
55
+
56
+ export default meta;
57
+ type Story = StoryObj<typeof meta>;
58
+
59
+ export const Playground: Story = {
60
+ render: (args) => <Box {...args}>Box — styled by props, no classes.</Box>,
61
+ };
62
+
63
+ export const Surfaces: Story = {
64
+ render: () => (
65
+ <Box display='flex' style={{ gap: '1rem', flexWrap: 'wrap' }}>
66
+ <Box p={6} bg='card' radius='lg' shadow='sm' border width={180}>
67
+ card · shadow sm
68
+ </Box>
69
+ <Box p={6} bg='muted' radius='lg' width={180}>
70
+ muted
71
+ </Box>
72
+ <Box
73
+ p={6}
74
+ bg='primary'
75
+ color='primary-foreground'
76
+ radius='lg'
77
+ width={180}
78
+ >
79
+ primary
80
+ </Box>
81
+ <Box p={6} bg='vinta-50' color='vinta-700' radius='xl' width={180}>
82
+ vinta-50 / 700
83
+ </Box>
84
+ </Box>
85
+ ),
86
+ };
@@ -0,0 +1,34 @@
1
+ import * as React from 'react';
2
+
3
+ import { cn } from '../lib/utils';
4
+ import { boxStyle, splitBoxProps, type BoxStyleProps } from './layout-style';
5
+
6
+ export interface BoxProps
7
+ extends BoxStyleProps, Omit<React.HTMLAttributes<HTMLElement>, 'color'> {
8
+ /** Element/component to render. Defaults to `div`. */
9
+ as?: React.ElementType;
10
+ }
11
+
12
+ /**
13
+ * Box — the primitive every other layout component builds on. A styled element
14
+ * driven entirely by token props (padding, margin, bg, radius, shadow, sizing…)
15
+ * instead of utility classes.
16
+ *
17
+ * <Box p={4} bg="card" radius="lg" shadow="sm" border>…</Box>
18
+ */
19
+ const Box = React.forwardRef<HTMLElement, BoxProps>(function Box(
20
+ { as: Comp = 'div', className, style, ...props },
21
+ ref
22
+ ) {
23
+ const { style: resolved, rest } = splitBoxProps(props);
24
+ return (
25
+ <Comp
26
+ ref={ref}
27
+ className={cn(className)}
28
+ style={{ ...resolved, ...style }}
29
+ {...rest}
30
+ />
31
+ );
32
+ });
33
+
34
+ export { Box, boxStyle };
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+
3
+ import { Flex, type FlexProps } from './flex';
4
+
5
+ /**
6
+ * Center — centers its children on both axes. Pass `width`/`height`/`minHeight`
7
+ * to size the centering area (e.g. `minHeight="screen"`).
8
+ */
9
+ const Center = React.forwardRef<
10
+ HTMLElement,
11
+ Omit<FlexProps, 'align' | 'justify'>
12
+ >(function Center({ inline, ...props }, ref) {
13
+ return (
14
+ <Flex
15
+ ref={ref}
16
+ align='center'
17
+ justify='center'
18
+ inline={inline}
19
+ {...props}
20
+ />
21
+ );
22
+ });
23
+
24
+ export { Center };
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '../story-types';
2
+
3
+ import { Container } from './container';
4
+
5
+ const meta = {
6
+ title: 'Layout/Container',
7
+ component: Container,
8
+ tags: ['autodocs'],
9
+ // `width` is Container's own prop; the rest come from BoxStyleProps, which
10
+ // Container extends. `children` is the composed content slot (§3).
11
+ argTypes: {
12
+ width: { control: 'inline-radio', options: ['contained', 'prose', 'full'] },
13
+ py: { control: 'select', options: [0, 2, 4, 6, 8, 12, 16] },
14
+ bg: {
15
+ control: 'select',
16
+ options: ['background', 'card', 'muted', 'accent'],
17
+ },
18
+ },
19
+ args: { width: 'contained' },
20
+ parameters: { layout: 'fullscreen', puck: { slots: ['children'] } },
21
+ } satisfies Meta<typeof Container>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ const Demo = () => (
27
+ <div className='border-primary/40 bg-accent/40 text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
28
+ Container content
29
+ </div>
30
+ );
31
+
32
+ export const Contained: Story = {
33
+ args: { width: 'contained' },
34
+ render: (args) => (
35
+ <div className='bg-muted/40 py-8'>
36
+ <Container {...args}>
37
+ <Demo />
38
+ </Container>
39
+ </div>
40
+ ),
41
+ };
42
+
43
+ export const Prose: Story = { ...Contained, args: { width: 'prose' } };
44
+ export const Full: Story = { ...Contained, args: { width: 'full' } };