@duro-app/ui 0.12.2 → 0.14.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 (134) hide show
  1. package/dist/components/PageShell/PageShell.d.ts +15 -0
  2. package/dist/components/PageShell/PageShell.d.ts.map +1 -0
  3. package/dist/components/PageShell/index.d.ts +3 -0
  4. package/dist/components/PageShell/index.d.ts.map +1 -0
  5. package/dist/components/PageShell/styles.css.d.ts +41 -0
  6. package/dist/components/PageShell/styles.css.d.ts.map +1 -0
  7. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  8. package/dist/index.css +1 -1
  9. package/dist/index.d.ts +1 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3283 -3434
  12. package/dist/index.js.map +1 -1
  13. package/package.json +4 -4
  14. package/src/components/Alert/Alert.stories.tsx +76 -0
  15. package/src/components/Alert/Alert.tsx +45 -0
  16. package/src/components/Alert/styles.css.ts +50 -0
  17. package/src/components/Badge/Badge.stories.tsx +94 -0
  18. package/src/components/Badge/Badge.tsx +21 -0
  19. package/src/components/Badge/styles.css.ts +51 -0
  20. package/src/components/Button/Button.stories.tsx +130 -0
  21. package/src/components/Button/Button.tsx +48 -0
  22. package/src/components/Button/styles.css.ts +107 -0
  23. package/src/components/Callout/Callout.stories.tsx +97 -0
  24. package/src/components/Callout/Callout.tsx +39 -0
  25. package/src/components/Callout/index.ts +1 -0
  26. package/src/components/Callout/styles.css.ts +45 -0
  27. package/src/components/Card/Card.stories.tsx +119 -0
  28. package/src/components/Card/Card.tsx +35 -0
  29. package/src/components/Card/styles.css.ts +67 -0
  30. package/src/components/Checkbox/Checkbox.stories.tsx +88 -0
  31. package/src/components/Checkbox/Checkbox.tsx +73 -0
  32. package/src/components/Checkbox/styles.css.ts +57 -0
  33. package/src/components/Cluster/Cluster.stories.tsx +92 -0
  34. package/src/components/Cluster/Cluster.tsx +43 -0
  35. package/src/components/Cluster/styles.css.ts +25 -0
  36. package/src/components/EmptyState/EmptyState.stories.tsx +54 -0
  37. package/src/components/EmptyState/EmptyState.tsx +19 -0
  38. package/src/components/EmptyState/styles.css.ts +25 -0
  39. package/src/components/Field/Field.stories.tsx +92 -0
  40. package/src/components/Field/Field.tsx +80 -0
  41. package/src/components/Field/FieldContext.ts +14 -0
  42. package/src/components/Field/styles.css.ts +25 -0
  43. package/src/components/Fieldset/Fieldset.stories.tsx +85 -0
  44. package/src/components/Fieldset/Fieldset.tsx +48 -0
  45. package/src/components/Fieldset/index.ts +1 -0
  46. package/src/components/Fieldset/styles.css.ts +33 -0
  47. package/src/components/Grid/Grid.stories.tsx +107 -0
  48. package/src/components/Grid/Grid.tsx +41 -0
  49. package/src/components/Grid/styles.css.ts +25 -0
  50. package/src/components/Heading/Heading.tsx +48 -0
  51. package/src/components/Heading/styles.css.ts +26 -0
  52. package/src/components/Icon/Icon.tsx +168 -0
  53. package/src/components/Icon/index.ts +2 -0
  54. package/src/components/Inline/Inline.stories.tsx +88 -0
  55. package/src/components/Inline/Inline.tsx +45 -0
  56. package/src/components/Inline/styles.css.ts +27 -0
  57. package/src/components/Input/Input.stories.tsx +89 -0
  58. package/src/components/Input/Input.tsx +77 -0
  59. package/src/components/Input/styles.css.ts +60 -0
  60. package/src/components/InputGroup/InputGroup.stories.tsx +119 -0
  61. package/src/components/InputGroup/InputGroup.tsx +60 -0
  62. package/src/components/InputGroup/InputGroupContext.ts +11 -0
  63. package/src/components/InputGroup/styles.css.ts +61 -0
  64. package/src/components/LinkButton/LinkButton.stories.tsx +91 -0
  65. package/src/components/LinkButton/LinkButton.tsx +42 -0
  66. package/src/components/LinkButton/styles.css.ts +56 -0
  67. package/src/components/Menu/Menu.stories.tsx +146 -0
  68. package/src/components/Menu/Menu.tsx +151 -0
  69. package/src/components/Menu/MenuContext.ts +20 -0
  70. package/src/components/Menu/styles.css.ts +89 -0
  71. package/src/components/Menu/useMenuRoot.ts +136 -0
  72. package/src/components/PageShell/PageShell.tsx +45 -0
  73. package/src/components/PageShell/index.ts +2 -0
  74. package/src/components/PageShell/styles.css.ts +26 -0
  75. package/src/components/ScrollArea/ScrollArea.stories.tsx +82 -0
  76. package/src/components/ScrollArea/ScrollArea.tsx +170 -0
  77. package/src/components/ScrollArea/ScrollAreaContext.ts +21 -0
  78. package/src/components/ScrollArea/styles.css.ts +81 -0
  79. package/src/components/ScrollArea/useScrollAreaRoot.ts +72 -0
  80. package/src/components/Select/Select.stories.tsx +144 -0
  81. package/src/components/Select/Select.tsx +183 -0
  82. package/src/components/Select/SelectContext.ts +24 -0
  83. package/src/components/Select/styles.css.ts +97 -0
  84. package/src/components/Select/useSelectRoot.ts +178 -0
  85. package/src/components/SideNav/SideNav.stories.tsx +77 -0
  86. package/src/components/SideNav/SideNav.tsx +172 -0
  87. package/src/components/SideNav/SideNavContext.ts +18 -0
  88. package/src/components/SideNav/styles.css.ts +95 -0
  89. package/src/components/Spinner/Spinner.stories.tsx +59 -0
  90. package/src/components/Spinner/Spinner.tsx +24 -0
  91. package/src/components/Spinner/styles.css.ts +47 -0
  92. package/src/components/Stack/Stack.stories.tsx +103 -0
  93. package/src/components/Stack/Stack.tsx +33 -0
  94. package/src/components/Stack/styles.css.ts +21 -0
  95. package/src/components/StatusIcon/StatusIcon.stories.tsx +81 -0
  96. package/src/components/StatusIcon/StatusIcon.tsx +24 -0
  97. package/src/components/StatusIcon/styles.css.ts +27 -0
  98. package/src/components/Switch/Switch.stories.tsx +88 -0
  99. package/src/components/Switch/Switch.tsx +78 -0
  100. package/src/components/Switch/styles.css.ts +71 -0
  101. package/src/components/Table/Table.stories.tsx +308 -0
  102. package/src/components/Table/Table.tsx +179 -0
  103. package/src/components/Table/styles.css.ts +97 -0
  104. package/src/components/Tabs/Tabs.stories.tsx +142 -0
  105. package/src/components/Tabs/Tabs.tsx +210 -0
  106. package/src/components/Tabs/TabsContext.ts +20 -0
  107. package/src/components/Tabs/styles.css.ts +98 -0
  108. package/src/components/Tabs/useTabsRoot.ts +42 -0
  109. package/src/components/Text/Text.tsx +52 -0
  110. package/src/components/Text/styles.css.ts +57 -0
  111. package/src/components/Textarea/Textarea.stories.tsx +80 -0
  112. package/src/components/Textarea/Textarea.tsx +50 -0
  113. package/src/components/Textarea/styles.css.ts +56 -0
  114. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +163 -0
  115. package/src/components/ThemeProvider/ThemeProvider.tsx +33 -0
  116. package/src/components/Toggle/Toggle.stories.tsx +84 -0
  117. package/src/components/Toggle/Toggle.tsx +85 -0
  118. package/src/components/Toggle/styles.css.ts +66 -0
  119. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +159 -0
  120. package/src/components/ToggleGroup/ToggleGroup.tsx +63 -0
  121. package/src/components/ToggleGroup/ToggleGroupContext.ts +18 -0
  122. package/src/components/ToggleGroup/styles.css.ts +17 -0
  123. package/src/components/Tooltip/Tooltip.stories.tsx +127 -0
  124. package/src/components/Tooltip/Tooltip.tsx +97 -0
  125. package/src/components/Tooltip/styles.css.ts +56 -0
  126. package/src/docs/Spacing.mdx +80 -0
  127. package/src/docs/Spacing.stories.tsx +202 -0
  128. package/src/docs/Typography.mdx +93 -0
  129. package/src/docs/Typography.stories.tsx +211 -0
  130. package/src/docs/helpers.tsx +135 -0
  131. package/src/hooks/useContainerQuery.ts +54 -0
  132. package/src/hooks/useControllableValue.ts +18 -0
  133. package/src/index.ts +56 -0
  134. package/src/stubs/assets-registry.ts +3 -0
@@ -0,0 +1,63 @@
1
+ import {type ReactNode, useCallback} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useControllableValue} from '../../hooks/useControllableValue'
4
+ import type {ToggleSize} from '../Toggle/Toggle'
5
+ import {ToggleGroupContext, type Orientation} from './ToggleGroupContext'
6
+ import {styles} from './styles.css'
7
+
8
+ interface ToggleGroupProps {
9
+ /** Controlled value — array of pressed item values. */
10
+ value?: string[]
11
+ /** Initial value (uncontrolled). */
12
+ defaultValue?: string[]
13
+ /** Callback fired when the set of pressed values changes. */
14
+ onValueChange?: (value: string[]) => void
15
+ /** When false, at most one item can be pressed at a time. */
16
+ multiple?: boolean
17
+ /** Prevents interaction with all items. */
18
+ disabled?: boolean
19
+ /** Layout direction. */
20
+ orientation?: Orientation
21
+ /** Size applied to all child toggles. */
22
+ size?: ToggleSize
23
+ children: ReactNode
24
+ }
25
+
26
+ export function ToggleGroup({
27
+ value: controlledValue,
28
+ defaultValue = [],
29
+ onValueChange,
30
+ multiple = false,
31
+ disabled = false,
32
+ orientation = 'horizontal',
33
+ size = 'default',
34
+ children,
35
+ }: ToggleGroupProps) {
36
+ const [value, setValue] = useControllableValue(controlledValue, defaultValue, onValueChange)
37
+
38
+ const toggle = useCallback(
39
+ (itemValue: string) => {
40
+ const nextPressed = !value.includes(itemValue)
41
+ let next: string[]
42
+ if (multiple) {
43
+ next = nextPressed ? [...value, itemValue] : value.filter((v) => v !== itemValue)
44
+ } else {
45
+ next = nextPressed ? [itemValue] : []
46
+ }
47
+ setValue(next)
48
+ },
49
+ [value, multiple, setValue],
50
+ )
51
+
52
+ return (
53
+ <ToggleGroupContext.Provider value={{value, toggle, disabled, orientation, size}}>
54
+ <html.div
55
+ role="group"
56
+ aria-orientation={orientation}
57
+ style={[styles.root, orientation === 'vertical' && styles.vertical]}
58
+ >
59
+ {children}
60
+ </html.div>
61
+ </ToggleGroupContext.Provider>
62
+ )
63
+ }
@@ -0,0 +1,18 @@
1
+ import {createContext, useContext} from 'react'
2
+ import type {ToggleSize} from '../Toggle/Toggle'
3
+
4
+ export type Orientation = 'horizontal' | 'vertical'
5
+
6
+ export interface ToggleGroupContextValue {
7
+ value: string[]
8
+ toggle: (itemValue: string) => void
9
+ disabled: boolean
10
+ orientation: Orientation
11
+ size: ToggleSize
12
+ }
13
+
14
+ export const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null)
15
+
16
+ export function useToggleGroup() {
17
+ return useContext(ToggleGroupContext)
18
+ }
@@ -0,0 +1,17 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii} from '@duro-app/tokens/tokens/spacing.css'
4
+
5
+ export const styles = css.create({
6
+ root: {
7
+ display: 'inline-flex',
8
+ borderRadius: radii.sm,
9
+ borderWidth: 1,
10
+ borderStyle: 'solid',
11
+ borderColor: colors.border,
12
+ overflow: 'hidden',
13
+ },
14
+ vertical: {
15
+ flexDirection: 'column',
16
+ },
17
+ })
@@ -0,0 +1,127 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Tooltip} from './Tooltip'
5
+ import {Badge} from '../Badge/Badge'
6
+
7
+ const meta: Meta = {
8
+ title: 'Components/Tooltip',
9
+ }
10
+
11
+ export default meta
12
+ type Story = StoryObj
13
+
14
+ const centerStyles = css.create({
15
+ center: {
16
+ display: 'flex',
17
+ alignItems: 'center',
18
+ justifyContent: 'center',
19
+ padding: 80,
20
+ gap: 24,
21
+ },
22
+ })
23
+
24
+ export const Default: Story = {
25
+ render: () => (
26
+ <html.div style={centerStyles.center}>
27
+ <Tooltip.Root content="This is a tooltip" delay={0}>
28
+ <Tooltip.Trigger>
29
+ <html.span>Hover me</html.span>
30
+ </Tooltip.Trigger>
31
+ </Tooltip.Root>
32
+ </html.div>
33
+ ),
34
+ play: async ({canvas, userEvent}) => {
35
+ const trigger = canvas.getByText('Hover me')
36
+
37
+ // Tooltip not visible initially
38
+ await expect(canvas.queryByRole('tooltip')).not.toBeInTheDocument()
39
+
40
+ // Hover to show
41
+ await userEvent.hover(trigger)
42
+ const tooltip = canvas.getByRole('tooltip')
43
+ await expect(tooltip).toBeInTheDocument()
44
+ await expect(tooltip).toHaveTextContent('This is a tooltip')
45
+
46
+ // Unhover to hide
47
+ await userEvent.unhover(trigger)
48
+ await expect(canvas.queryByRole('tooltip')).not.toBeInTheDocument()
49
+ },
50
+ }
51
+
52
+ export const Placements: Story = {
53
+ render: () => (
54
+ <html.div style={centerStyles.center}>
55
+ <Tooltip.Root content="Top tooltip" placement="top">
56
+ <Tooltip.Trigger>
57
+ <Badge>Top</Badge>
58
+ </Tooltip.Trigger>
59
+ </Tooltip.Root>
60
+ <Tooltip.Root content="Bottom tooltip" placement="bottom">
61
+ <Tooltip.Trigger>
62
+ <Badge>Bottom</Badge>
63
+ </Tooltip.Trigger>
64
+ </Tooltip.Root>
65
+ <Tooltip.Root content="Left tooltip" placement="left">
66
+ <Tooltip.Trigger>
67
+ <Badge>Left</Badge>
68
+ </Tooltip.Trigger>
69
+ </Tooltip.Root>
70
+ <Tooltip.Root content="Right tooltip" placement="right">
71
+ <Tooltip.Trigger>
72
+ <Badge>Right</Badge>
73
+ </Tooltip.Trigger>
74
+ </Tooltip.Root>
75
+ </html.div>
76
+ ),
77
+ }
78
+
79
+ export const WithBadge: Story = {
80
+ render: () => (
81
+ <html.div style={centerStyles.center}>
82
+ <Tooltip.Root content="Security score: A+" delay={0}>
83
+ <Tooltip.Trigger>
84
+ <Badge variant="success">Secure</Badge>
85
+ </Tooltip.Trigger>
86
+ </Tooltip.Root>
87
+ <Tooltip.Root content="Certificate expires in 3 days" delay={0}>
88
+ <Tooltip.Trigger>
89
+ <Badge variant="warning">Expiring</Badge>
90
+ </Tooltip.Trigger>
91
+ </Tooltip.Root>
92
+ <Tooltip.Root content="Certificate has expired" delay={0}>
93
+ <Tooltip.Trigger>
94
+ <Badge variant="error">Expired</Badge>
95
+ </Tooltip.Trigger>
96
+ </Tooltip.Root>
97
+ </html.div>
98
+ ),
99
+ play: async ({canvas, userEvent}) => {
100
+ // Hover first badge
101
+ await userEvent.hover(canvas.getByText('Secure'))
102
+ await expect(canvas.getByRole('tooltip')).toHaveTextContent('Security score: A+')
103
+ await userEvent.unhover(canvas.getByText('Secure'))
104
+
105
+ // Hover second badge
106
+ await userEvent.hover(canvas.getByText('Expiring'))
107
+ await expect(canvas.getByRole('tooltip')).toHaveTextContent('Certificate expires in 3 days')
108
+ await userEvent.unhover(canvas.getByText('Expiring'))
109
+ },
110
+ }
111
+
112
+ export const CustomDelay: Story = {
113
+ render: () => (
114
+ <html.div style={centerStyles.center}>
115
+ <Tooltip.Root content="Instant tooltip" delay={0}>
116
+ <Tooltip.Trigger>
117
+ <Badge variant="info">No delay</Badge>
118
+ </Tooltip.Trigger>
119
+ </Tooltip.Root>
120
+ <Tooltip.Root content="Slow tooltip" delay={800}>
121
+ <Tooltip.Trigger>
122
+ <Badge variant="info">800ms delay</Badge>
123
+ </Tooltip.Trigger>
124
+ </Tooltip.Root>
125
+ </html.div>
126
+ ),
127
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ type ReactNode,
3
+ createContext,
4
+ useContext,
5
+ useState,
6
+ useRef,
7
+ useCallback,
8
+ useId,
9
+ } from 'react'
10
+ import {html} from 'react-strict-dom'
11
+ import {styles} from './styles.css'
12
+
13
+ // --- Context ---
14
+
15
+ type Placement = 'top' | 'bottom' | 'left' | 'right'
16
+
17
+ interface TooltipContextValue {
18
+ open: boolean
19
+ show: () => void
20
+ hide: () => void
21
+ tooltipId: string
22
+ placement: Placement
23
+ }
24
+
25
+ const TooltipContext = createContext<TooltipContextValue | null>(null)
26
+
27
+ function useTooltip() {
28
+ const ctx = useContext(TooltipContext)
29
+ if (!ctx) throw new Error('Tooltip compound components must be used within Tooltip.Root')
30
+ return ctx
31
+ }
32
+
33
+ // --- Root ---
34
+
35
+ interface RootProps {
36
+ children: ReactNode
37
+ content: ReactNode
38
+ placement?: Placement
39
+ delay?: number
40
+ }
41
+
42
+ function Root({children, content, placement = 'top', delay = 300}: RootProps) {
43
+ const [open, setOpen] = useState(false)
44
+ const tooltipId = useId()
45
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
46
+
47
+ const show = useCallback(() => {
48
+ if (timerRef.current) clearTimeout(timerRef.current)
49
+ timerRef.current = setTimeout(() => setOpen(true), delay)
50
+ }, [delay])
51
+
52
+ const hide = useCallback(() => {
53
+ if (timerRef.current) clearTimeout(timerRef.current)
54
+ timerRef.current = null
55
+ setOpen(false)
56
+ }, [])
57
+
58
+ return (
59
+ <TooltipContext.Provider value={{open, show, hide, tooltipId, placement}}>
60
+ <html.div style={styles.root}>
61
+ {children}
62
+ {open && (
63
+ <html.div id={tooltipId} role="tooltip" style={[styles.popup, styles[placement]]}>
64
+ {content}
65
+ </html.div>
66
+ )}
67
+ </html.div>
68
+ </TooltipContext.Provider>
69
+ )
70
+ }
71
+
72
+ // --- Trigger ---
73
+
74
+ interface TriggerProps {
75
+ children: ReactNode
76
+ }
77
+
78
+ function Trigger({children}: TriggerProps) {
79
+ const {open, show, hide, tooltipId} = useTooltip()
80
+
81
+ return (
82
+ <html.div
83
+ onPointerEnter={show}
84
+ onPointerLeave={hide}
85
+ onFocus={show}
86
+ onBlur={hide}
87
+ aria-describedby={open ? tooltipId : undefined}
88
+ >
89
+ {children}
90
+ </html.div>
91
+ )
92
+ }
93
+
94
+ export const Tooltip = {
95
+ Root,
96
+ Trigger,
97
+ }
@@ -0,0 +1,56 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
+
7
+ export const styles = css.create({
8
+ root: {
9
+ position: 'relative',
10
+ display: 'inline-flex',
11
+ },
12
+ popup: {
13
+ position: 'absolute',
14
+ zIndex: 50,
15
+ paddingTop: spacing.xs,
16
+ paddingBottom: spacing.xs,
17
+ paddingLeft: spacing.sm,
18
+ paddingRight: spacing.sm,
19
+ backgroundColor: colors.bgCard,
20
+ color: colors.text,
21
+ fontFamily: typography.fontFamily,
22
+ fontSize: typography.fontSizeXs,
23
+ lineHeight: typography.lineHeight,
24
+ borderRadius: radii.sm,
25
+ borderWidth: 1,
26
+ borderStyle: 'solid',
27
+ borderColor: colors.border,
28
+ boxShadow: shadows.md,
29
+ whiteSpace: 'nowrap',
30
+ pointerEvents: 'none',
31
+ },
32
+ top: {
33
+ bottom: '100%',
34
+ left: '50%',
35
+ transform: 'translateX(-50%)',
36
+ marginBottom: spacing.xs,
37
+ },
38
+ bottom: {
39
+ top: '100%',
40
+ left: '50%',
41
+ transform: 'translateX(-50%)',
42
+ marginTop: spacing.xs,
43
+ },
44
+ left: {
45
+ right: '100%',
46
+ top: '50%',
47
+ transform: 'translateY(-50%)',
48
+ marginRight: spacing.xs,
49
+ },
50
+ right: {
51
+ left: '100%',
52
+ top: '50%',
53
+ transform: 'translateY(-50%)',
54
+ marginLeft: spacing.xs,
55
+ },
56
+ })
@@ -0,0 +1,80 @@
1
+ {/* packages/ui/src/docs/Spacing.mdx */}
2
+ import { Meta } from '@storybook/addon-docs/blocks'
3
+ import { TokenTable } from './helpers'
4
+
5
+ <Meta title="Foundations/Spacing/Overview" />
6
+
7
+ # Spacing System
8
+
9
+ The spacing system uses **three layers** — primitive tokens, semantic tokens, and layout components — to create consistent, maintainable spacing throughout the UI.
10
+
11
+ ## Philosophy
12
+
13
+ - **24px baseline grid** — all spacing values are multiples or fractions of 24px, ensuring vertical rhythm.
14
+ - **Primitive tokens** define the raw scale (`xs` through `xxxl`).
15
+ - **Semantic tokens** give meaning to the scale: `stackMd`, `inlineSm`, `containerLg` tell you _where_ a value is used, not just _how big_ it is.
16
+ - **Layout components** (`Stack`, `Inline`, `Cluster`, `Grid`) apply these tokens so you rarely write raw gap/padding values.
17
+
18
+ ## Primitive Scale
19
+
20
+ These are the base spacing values imported from `@duro-app/tokens`:
21
+
22
+ | Token | Value |
23
+ | ------ | ----- |
24
+ | `xs` | 4px |
25
+ | `sm` | 8px |
26
+ | `ms` | 12px |
27
+ | `md` | 16px |
28
+ | `lg` | 24px |
29
+ | `xl` | 32px |
30
+ | `xxl` | 48px |
31
+ | `xxxl` | 64px |
32
+
33
+ See the **Primitive Scale** story for a visual representation.
34
+
35
+ ## Semantic Tokens
36
+
37
+ Semantic tokens map primitives to usage contexts:
38
+
39
+ ### Stack (vertical gaps)
40
+
41
+ | Token | Value |
42
+ | --------- | ----- |
43
+ | `stackXs` | 4px |
44
+ | `stackSm` | 8px |
45
+ | `stackMd` | 16px |
46
+ | `stackLg` | 24px |
47
+ | `stackXl` | 48px |
48
+
49
+ ### Inline (horizontal gaps)
50
+
51
+ | Token | Value |
52
+ | ---------- | ----- |
53
+ | `inlineXs` | 4px |
54
+ | `inlineSm` | 8px |
55
+ | `inlineMd` | 16px |
56
+ | `inlineLg` | 24px |
57
+
58
+ ### Container (insets/padding)
59
+
60
+ | Token | Value |
61
+ | ------------- | ----- |
62
+ | `containerSm` | 16px |
63
+ | `containerMd` | 24px |
64
+ | `containerLg` | 32px |
65
+
66
+ ## When to use which layer
67
+
68
+ | Scenario | Use |
69
+ | ------------------------------------------- | ----------------------------------- |
70
+ | Building a layout with vertical flow | `<Stack gap="md">` |
71
+ | Horizontal row of items | `<Inline gap="sm">` |
72
+ | Card or section padding | `containerMd` / `containerLg` token |
73
+ | One-off custom spacing in a component | Primitive token (`spacing.lg`) |
74
+ | Responsive spacing based on container width | `useContainerQuery` hook |
75
+
76
+ ## Responsive Spacing
77
+
78
+ The `useContainerQuery` hook returns `compact`, `default`, or `spacious` based on the container width. Components can use this to adjust spacing at the component level rather than relying on viewport media queries.
79
+
80
+ See the **Responsive Spacing** story for an interactive demo.
@@ -0,0 +1,202 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {css, html} from 'react-strict-dom'
3
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
5
+ import {Stack} from '../components/Stack/Stack'
6
+ import {Inline} from '../components/Inline/Inline'
7
+ import {useContainerQuery} from '../hooks/useContainerQuery'
8
+ import {TokenTable} from './helpers'
9
+
10
+ const primitiveTokens: Record<string, string> = {
11
+ xs: '4px',
12
+ sm: '8px',
13
+ ms: '12px',
14
+ md: '16px',
15
+ lg: '24px',
16
+ xl: '32px',
17
+ xxl: '48px',
18
+ xxxl: '64px',
19
+ }
20
+
21
+ const semanticTokens = {
22
+ stack: {stackXs: '4px', stackSm: '8px', stackMd: '16px', stackLg: '24px', stackXl: '48px'},
23
+ inline: {inlineXs: '4px', inlineSm: '8px', inlineMd: '16px', inlineLg: '24px'},
24
+ container: {containerSm: '16px', containerMd: '24px', containerLg: '32px'},
25
+ }
26
+
27
+ const meta: Meta = {
28
+ title: 'Foundations/Spacing',
29
+ }
30
+
31
+ export default meta
32
+
33
+ const styles = css.create({
34
+ wrapper: {
35
+ maxWidth: 640,
36
+ },
37
+ hint: {
38
+ fontSize: '0.75rem',
39
+ color: colors.textMuted,
40
+ fontStyle: 'italic',
41
+ },
42
+ baselineRow: {
43
+ height: 24,
44
+ display: 'flex',
45
+ alignItems: 'center',
46
+ borderBottomWidth: 1,
47
+ borderBottomStyle: 'dashed',
48
+ borderBottomColor: 'rgba(0,0,0,0.1)',
49
+ },
50
+ baselineLabel: {
51
+ fontSize: '0.75rem',
52
+ fontFamily: 'monospace',
53
+ color: colors.textMuted,
54
+ width: 50,
55
+ },
56
+ baselineBlock: {
57
+ flexGrow: 1,
58
+ height: 24,
59
+ backgroundColor: colors.accent,
60
+ opacity: 0.15,
61
+ borderRadius: 2,
62
+ },
63
+ resizableContainer: {
64
+ resize: 'horizontal',
65
+ overflow: 'auto',
66
+ borderWidth: 2,
67
+ borderStyle: 'dashed',
68
+ borderColor: colors.border,
69
+ padding: spacing.md,
70
+ minWidth: 200,
71
+ maxWidth: '100%',
72
+ width: 600,
73
+ },
74
+ sizeLabel: {
75
+ fontSize: '1.5rem',
76
+ fontWeight: 700,
77
+ fontFamily: 'monospace',
78
+ },
79
+ sizeCompact: {
80
+ color: '#e67e22',
81
+ },
82
+ sizeDefault: {
83
+ color: colors.accent,
84
+ },
85
+ sizeSpacious: {
86
+ color: '#27ae60',
87
+ },
88
+ mappingRow: {
89
+ borderBottomWidth: 1,
90
+ borderBottomStyle: 'solid',
91
+ borderBottomColor: colors.border,
92
+ fontFamily: 'monospace',
93
+ fontSize: '0.8125rem',
94
+ paddingTop: spacing.xs,
95
+ paddingBottom: spacing.xs,
96
+ },
97
+ mappingLabel: {
98
+ color: colors.textMuted,
99
+ },
100
+ mappingValue: {
101
+ fontWeight: 600,
102
+ },
103
+ })
104
+
105
+ export const PrimitiveScale: StoryObj = {
106
+ render: () => (
107
+ <html.div style={styles.wrapper}>
108
+ <TokenTable tokens={primitiveTokens} label="Primitive Spacing Tokens" />
109
+ </html.div>
110
+ ),
111
+ }
112
+
113
+ export const SemanticTokens: StoryObj = {
114
+ render: () => (
115
+ <html.div style={styles.wrapper}>
116
+ <Stack gap="xl">
117
+ <TokenTable tokens={semanticTokens.stack} label="Stack (vertical)" />
118
+ <TokenTable tokens={semanticTokens.inline} label="Inline (horizontal)" />
119
+ <TokenTable tokens={semanticTokens.container} label="Container (insets)" />
120
+ </Stack>
121
+ </html.div>
122
+ ),
123
+ }
124
+
125
+ export const BaselineRhythm: StoryObj = {
126
+ render: () => {
127
+ const rows = Array.from({length: 12}, (_, i) => i)
128
+ return (
129
+ <html.div style={styles.wrapper}>
130
+ <Stack gap="md">
131
+ <html.p style={styles.hint}>
132
+ Each row is 24px tall — the baseline grid unit. Spacing tokens are multiples or
133
+ fractions of this grid.
134
+ </html.p>
135
+ <html.div>
136
+ {rows.map((i) => (
137
+ <html.div key={i} style={styles.baselineRow}>
138
+ <html.span style={styles.baselineLabel}>{(i + 1) * 24}px</html.span>
139
+ {i % 2 === 0 && <html.div style={styles.baselineBlock} />}
140
+ </html.div>
141
+ ))}
142
+ </html.div>
143
+ </Stack>
144
+ </html.div>
145
+ )
146
+ },
147
+ }
148
+
149
+ const spacingMappings: Record<string, Record<string, string>> = {
150
+ compact: {
151
+ stackGap: '4px – 8px',
152
+ inlineGap: '4px – 8px',
153
+ containerPad: '12px – 16px',
154
+ },
155
+ default: {
156
+ stackGap: '8px – 16px',
157
+ inlineGap: '8px – 16px',
158
+ containerPad: '16px – 24px',
159
+ },
160
+ spacious: {
161
+ stackGap: '16px – 24px',
162
+ inlineGap: '16px – 24px',
163
+ containerPad: '24px – 32px',
164
+ },
165
+ }
166
+
167
+ function ResponsiveDemo() {
168
+ const {ref, size} = useContainerQuery<HTMLDivElement>()
169
+ const sizeStyle =
170
+ size === 'compact'
171
+ ? styles.sizeCompact
172
+ : size === 'spacious'
173
+ ? styles.sizeSpacious
174
+ : styles.sizeDefault
175
+ const mapping = spacingMappings[size]
176
+
177
+ return (
178
+ <Stack gap="md">
179
+ <html.p style={styles.hint}>
180
+ Drag the right edge of the container below to resize it. Breakpoints: compact &lt; 480px,
181
+ default 480–768px, spacious &gt; 768px.
182
+ </html.p>
183
+ <html.div ref={ref} style={styles.resizableContainer}>
184
+ <Stack gap="sm">
185
+ <html.div style={[styles.sizeLabel, sizeStyle]}>{size}</html.div>
186
+ {Object.entries(mapping).map(([key, value]) => (
187
+ <html.div key={key} style={styles.mappingRow}>
188
+ <Inline justify="between">
189
+ <html.span style={styles.mappingLabel}>{key}</html.span>
190
+ <html.span style={styles.mappingValue}>{value}</html.span>
191
+ </Inline>
192
+ </html.div>
193
+ ))}
194
+ </Stack>
195
+ </html.div>
196
+ </Stack>
197
+ )
198
+ }
199
+
200
+ export const ResponsiveSpacing: StoryObj = {
201
+ render: () => <ResponsiveDemo />,
202
+ }