@sovereignfs/ui 0.21.3 → 0.33.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 (130) hide show
  1. package/dist/Button.module.css +75 -8
  2. package/dist/Calendar.module.css +126 -0
  3. package/dist/Card.module.css +4 -2
  4. package/dist/Checkbox.module.css +109 -0
  5. package/dist/CodeTextarea.module.css +53 -0
  6. package/dist/ConfirmDialog.module.css +109 -0
  7. package/dist/DatePicker.module.css +57 -0
  8. package/dist/Dialog.module.css +73 -46
  9. package/dist/DragHandleRow.module.css +57 -0
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/FormField.module.css +5 -0
  12. package/dist/Icon.module.css +5 -0
  13. package/dist/Input.module.css +10 -0
  14. package/dist/Menu.module.css +82 -0
  15. package/dist/NavTabs.module.css +4 -2
  16. package/dist/OverlayHeader.module.css +98 -0
  17. package/dist/Popover.module.css +31 -1
  18. package/dist/SegmentedControl.module.css +4 -2
  19. package/dist/Select.module.css +9 -0
  20. package/dist/Sheet.module.css +55 -0
  21. package/dist/SplitPane.module.css +85 -0
  22. package/dist/StatusBadge.module.css +59 -0
  23. package/dist/SystemBanner.module.css +4 -2
  24. package/dist/Tabs.module.css +4 -2
  25. package/dist/TagInput.module.css +141 -0
  26. package/dist/Textarea.module.css +39 -0
  27. package/dist/Toast.module.css +7 -5
  28. package/dist/Toggle.module.css +1 -1
  29. package/dist/Tooltip.module.css +6 -1
  30. package/dist/index.d.ts +549 -20
  31. package/dist/index.js +1496 -193
  32. package/dist/tokens/primitives.css +19 -0
  33. package/dist/tokens/semantic.css +19 -0
  34. package/package.json +1 -1
  35. package/src/__tests__/motion.test.tsx +105 -0
  36. package/src/components/Button/Button.module.css +75 -8
  37. package/src/components/Button/Button.stories.tsx +6 -0
  38. package/src/components/Button/Button.tsx +17 -1
  39. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  40. package/src/components/Calendar/Calendar.module.css +126 -0
  41. package/src/components/Calendar/Calendar.tsx +213 -0
  42. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  43. package/src/components/Calendar/dateUtils.ts +97 -0
  44. package/src/components/Card/Card.module.css +4 -2
  45. package/src/components/Checkbox/Checkbox.module.css +109 -0
  46. package/src/components/Checkbox/Checkbox.tsx +78 -0
  47. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  48. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  49. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  50. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  51. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  52. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  53. package/src/components/DatePicker/DatePicker.module.css +57 -0
  54. package/src/components/DatePicker/DatePicker.tsx +103 -0
  55. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  56. package/src/components/Dialog/Dialog.module.css +73 -46
  57. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  58. package/src/components/Dialog/Dialog.tsx +86 -22
  59. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  60. package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
  61. package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
  62. package/src/components/Drawer/Drawer.module.css +61 -0
  63. package/src/components/Drawer/Drawer.tsx +117 -13
  64. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  65. package/src/components/FormField/FormField.module.css +5 -0
  66. package/src/components/FormField/FormField.tsx +36 -11
  67. package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
  68. package/src/components/Icon/Icon.module.css +5 -0
  69. package/src/components/Icon/Icon.stories.tsx +4 -3
  70. package/src/components/Icon/Icon.tsx +1 -1
  71. package/src/components/Icon/icons/calendar.tsx +23 -0
  72. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  73. package/src/components/Icon/icons/index.ts +8 -4
  74. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  75. package/src/components/Input/Input.module.css +10 -0
  76. package/src/components/Menu/Menu.module.css +82 -0
  77. package/src/components/Menu/Menu.tsx +137 -0
  78. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  79. package/src/components/NavTabs/NavTabs.module.css +4 -2
  80. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  81. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  82. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  83. package/src/components/Popover/Popover.module.css +31 -1
  84. package/src/components/Popover/Popover.stories.tsx +126 -1
  85. package/src/components/Popover/Popover.tsx +140 -7
  86. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  87. package/src/components/Select/Select.module.css +9 -0
  88. package/src/components/Select/Select.tsx +10 -2
  89. package/src/components/Sheet/Sheet.module.css +55 -0
  90. package/src/components/Sheet/Sheet.tsx +151 -0
  91. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  92. package/src/components/SplitPane/SplitPane.module.css +85 -0
  93. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  94. package/src/components/SplitPane/SplitPane.tsx +149 -0
  95. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  96. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  97. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  98. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  99. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  100. package/src/components/Tabs/Tabs.module.css +4 -2
  101. package/src/components/TagInput/TagInput.module.css +141 -0
  102. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  103. package/src/components/TagInput/TagInput.tsx +174 -0
  104. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  105. package/src/components/Textarea/Textarea.module.css +39 -0
  106. package/src/components/Textarea/Textarea.tsx +15 -0
  107. package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
  108. package/src/components/Toast/Toast.module.css +7 -5
  109. package/src/components/Toggle/Toggle.module.css +1 -1
  110. package/src/components/Tooltip/Tooltip.module.css +6 -1
  111. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  112. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  113. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  114. package/src/hooks/index.ts +4 -0
  115. package/src/hooks/useDoubleTap.ts +91 -0
  116. package/src/hooks/useIsMobile.ts +33 -0
  117. package/src/hooks/useLongPress.ts +185 -0
  118. package/src/index.ts +37 -2
  119. package/src/motion.ts +78 -0
  120. package/src/stories/Checkbox.stories.tsx +45 -0
  121. package/src/stories/DesignSystemOverview.stories.tsx +354 -8
  122. package/src/stories/DragHandleRow.stories.tsx +41 -0
  123. package/src/stories/FormField.stories.tsx +8 -8
  124. package/src/stories/InteractionHooks.stories.tsx +388 -0
  125. package/src/stories/MobilePatterns.stories.tsx +4 -4
  126. package/src/stories/Textarea.stories.tsx +51 -0
  127. package/src/stories/TokenGallery.stories.tsx +87 -1
  128. package/src/tokens/primitives.css +19 -0
  129. package/src/tokens/semantic.css +19 -0
  130. package/src/components/Icon/icons/terminal.tsx +0 -19
@@ -0,0 +1,388 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Card } from '../components/Card/Card';
4
+ import { useDoubleTapHandler, useIsMobile, useLongPress, useSingleOrDoubleTap } from '../hooks';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Shared primitives (mirrors MobilePatterns.stories.tsx's doc styling)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const ff = 'var(--sv-font-family)';
11
+
12
+ function Heading({ level = 2, children }: { level?: 1 | 2 | 3; children: React.ReactNode }) {
13
+ const sizes: Record<number, string> = { 1: '1.75rem', 2: '1.125rem', 3: '0.9375rem' };
14
+ const weights: Record<number, number> = { 1: 700, 2: 600, 3: 600 };
15
+ const Tag = `h${level}` as 'h1' | 'h2' | 'h3';
16
+ return (
17
+ <Tag
18
+ style={{
19
+ fontFamily: ff,
20
+ fontSize: sizes[level],
21
+ fontWeight: weights[level],
22
+ color: 'var(--sv-color-text-primary)',
23
+ margin: 0,
24
+ lineHeight: 1.3,
25
+ }}
26
+ >
27
+ {children}
28
+ </Tag>
29
+ );
30
+ }
31
+
32
+ function Body({ children }: { children: React.ReactNode }) {
33
+ return (
34
+ <p
35
+ style={{
36
+ fontFamily: ff,
37
+ fontSize: 'var(--sv-font-size-sm)',
38
+ color: 'var(--sv-color-text-muted)',
39
+ margin: '0.5rem 0 0',
40
+ lineHeight: 1.6,
41
+ }}
42
+ >
43
+ {children}
44
+ </p>
45
+ );
46
+ }
47
+
48
+ function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }) {
49
+ return (
50
+ <div
51
+ style={{
52
+ paddingBottom: 'var(--sv-space-3)',
53
+ borderBottom: '2px solid var(--sv-color-accent)',
54
+ marginBottom: 'var(--sv-space-5)',
55
+ }}
56
+ >
57
+ <Heading level={2}>{title}</Heading>
58
+ {subtitle && <Body>{subtitle}</Body>}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ function Callout({
64
+ type = 'info',
65
+ children,
66
+ }: {
67
+ type?: 'info' | 'warn' | 'tip';
68
+ children: React.ReactNode;
69
+ }) {
70
+ const map = {
71
+ info: {
72
+ bg: 'var(--sv-color-info-surface)',
73
+ border: 'var(--sv-color-info-border)',
74
+ text: 'var(--sv-color-info-text)',
75
+ label: 'ℹ',
76
+ },
77
+ warn: {
78
+ bg: 'var(--sv-color-warning-surface)',
79
+ border: 'var(--sv-color-warning-border)',
80
+ text: 'var(--sv-color-warning-text)',
81
+ label: '⚠',
82
+ },
83
+ tip: {
84
+ bg: 'var(--sv-color-success-surface)',
85
+ border: 'var(--sv-color-success-border)',
86
+ text: 'var(--sv-color-success-text)',
87
+ label: '✓',
88
+ },
89
+ };
90
+ const c = map[type];
91
+ return (
92
+ <div
93
+ style={{
94
+ background: c.bg,
95
+ border: `1px solid ${c.border}`,
96
+ borderRadius: 'var(--sv-radius-md)',
97
+ padding: 'var(--sv-space-3) var(--sv-space-4)',
98
+ display: 'flex',
99
+ gap: 'var(--sv-space-2)',
100
+ alignItems: 'flex-start',
101
+ marginBottom: 'var(--sv-space-4)',
102
+ }}
103
+ >
104
+ <span style={{ color: c.text, fontWeight: 700, flexShrink: 0, fontSize: '0.875rem' }}>
105
+ {c.label}
106
+ </span>
107
+ <p
108
+ style={{
109
+ fontFamily: ff,
110
+ fontSize: 'var(--sv-font-size-sm)',
111
+ color: c.text,
112
+ margin: 0,
113
+ lineHeight: 1.55,
114
+ }}
115
+ >
116
+ {children}
117
+ </p>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ function CodeBlock({ children }: { children: string }) {
123
+ return (
124
+ <pre
125
+ style={{
126
+ fontFamily: 'var(--sv-font-family-mono)',
127
+ fontSize: '0.75rem',
128
+ background: 'var(--sv-color-surface-sunken)',
129
+ border: '1px solid var(--sv-color-border)',
130
+ borderRadius: 'var(--sv-radius-md)',
131
+ padding: 'var(--sv-space-3) var(--sv-space-4)',
132
+ marginTop: 'var(--sv-space-3)',
133
+ overflowX: 'auto',
134
+ color: 'var(--sv-color-text-primary)',
135
+ lineHeight: 1.6,
136
+ }}
137
+ >
138
+ {children}
139
+ </pre>
140
+ );
141
+ }
142
+
143
+ function EventLog({ events }: { events: string[] }) {
144
+ return (
145
+ <div
146
+ style={{
147
+ marginTop: 'var(--sv-space-3)',
148
+ fontFamily: 'var(--sv-font-family-mono)',
149
+ fontSize: '0.75rem',
150
+ color: 'var(--sv-color-text-muted)',
151
+ background: 'var(--sv-color-surface-sunken)',
152
+ border: '1px solid var(--sv-color-border)',
153
+ borderRadius: 'var(--sv-radius-md)',
154
+ padding: 'var(--sv-space-2) var(--sv-space-3)',
155
+ minHeight: '2.5em',
156
+ }}
157
+ >
158
+ {events.length === 0 ? '(no events yet)' : events.slice(-5).join('\n')}
159
+ </div>
160
+ );
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Interactive demos
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function LongPressDemo() {
168
+ const [events, setEvents] = useState<string[]>([]);
169
+ const log = (msg: string) =>
170
+ setEvents((e) => [...e, `${new Date().toLocaleTimeString()} — ${msg}`]);
171
+ const longPress = useLongPress({ onLongPress: () => log('onLongPress fired') });
172
+
173
+ return (
174
+ <div>
175
+ <div
176
+ {...longPress}
177
+ style={{
178
+ ...longPress.style,
179
+ padding: 'var(--sv-space-5)',
180
+ borderRadius: 'var(--sv-radius-md)',
181
+ border: '1px dashed var(--sv-color-border-strong)',
182
+ background: 'var(--sv-color-surface)',
183
+ textAlign: 'center',
184
+ fontFamily: ff,
185
+ fontSize: 'var(--sv-font-size-sm)',
186
+ color: 'var(--sv-color-text-muted)',
187
+ cursor: 'default',
188
+ }}
189
+ >
190
+ Touch and hold (real touch input only — see note below)
191
+ </div>
192
+ <EventLog events={events} />
193
+ <Callout type="info">
194
+ The hook only arms for <code>pointerType === &apos;touch&apos;</code> — a desktop mouse
195
+ click never starts the timer, so mouse text-selection is never disabled. Test this on a real
196
+ touch device or with devtools&apos; device toolbar (which synthesizes real touch pointer
197
+ events), not a plain mouse click in this preview.
198
+ </Callout>
199
+ </div>
200
+ );
201
+ }
202
+
203
+ function DoubleTapDemo() {
204
+ const [events, setEvents] = useState<string[]>([]);
205
+ const log = (msg: string) =>
206
+ setEvents((e) => [...e, `${new Date().toLocaleTimeString()} — ${msg}`]);
207
+
208
+ const handleSwatchTap = useDoubleTapHandler<React.MouseEvent>(() => log('double-tap: swatch'));
209
+ const handleTitleTap = useSingleOrDoubleTap<React.MouseEvent>(
210
+ () => log('single tap: would navigate'),
211
+ () => log('double-tap: would rename'),
212
+ );
213
+
214
+ return (
215
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sv-space-3)' }}>
216
+ <div style={{ display: 'flex', gap: 'var(--sv-space-3)', alignItems: 'center' }}>
217
+ <button
218
+ type="button"
219
+ onClick={handleSwatchTap}
220
+ style={{
221
+ width: 40,
222
+ height: 40,
223
+ borderRadius: 'var(--sv-radius-full)',
224
+ background: 'var(--sv-color-accent)',
225
+ border: 'none',
226
+ cursor: 'pointer',
227
+ }}
228
+ aria-label="Double-tap demo swatch"
229
+ />
230
+ <span
231
+ style={{
232
+ fontFamily: ff,
233
+ fontSize: 'var(--sv-font-size-sm)',
234
+ color: 'var(--sv-color-text-muted)',
235
+ }}
236
+ >
237
+ useDoubleTapHandler — nothing to preempt, double-click/double-tap fires immediately
238
+ </span>
239
+ </div>
240
+ <div style={{ display: 'flex', gap: 'var(--sv-space-3)', alignItems: 'center' }}>
241
+ <button
242
+ type="button"
243
+ onClick={handleTitleTap}
244
+ style={{
245
+ padding: 'var(--sv-space-2) var(--sv-space-4)',
246
+ borderRadius: 'var(--sv-radius-md)',
247
+ background: 'var(--sv-color-surface-sunken)',
248
+ border: '1px solid var(--sv-color-border)',
249
+ fontFamily: ff,
250
+ fontSize: 'var(--sv-font-size-sm)',
251
+ cursor: 'pointer',
252
+ }}
253
+ >
254
+ List title
255
+ </button>
256
+ <span
257
+ style={{
258
+ fontFamily: ff,
259
+ fontSize: 'var(--sv-font-size-sm)',
260
+ color: 'var(--sv-color-text-muted)',
261
+ }}
262
+ >
263
+ useSingleOrDoubleTap — single tap deferred ~350ms so a double can preempt it
264
+ </span>
265
+ </div>
266
+ <EventLog events={events} />
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function IsMobileDemo() {
272
+ const isMobile = useIsMobile();
273
+ return (
274
+ <div
275
+ style={{
276
+ padding: 'var(--sv-space-4)',
277
+ borderRadius: 'var(--sv-radius-md)',
278
+ background: isMobile ? 'var(--sv-color-success-surface)' : 'var(--sv-color-info-surface)',
279
+ color: isMobile ? 'var(--sv-color-success-text)' : 'var(--sv-color-info-text)',
280
+ fontFamily: ff,
281
+ fontSize: 'var(--sv-font-size-sm)',
282
+ fontWeight: 600,
283
+ textAlign: 'center',
284
+ }}
285
+ >
286
+ useIsMobile() → {String(isMobile)} — resize the preview or switch the viewport toolbar to see
287
+ this flip at 768px.
288
+ </div>
289
+ );
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Main story component
294
+ // ---------------------------------------------------------------------------
295
+
296
+ function InteractionHooksDoc() {
297
+ return (
298
+ <div
299
+ style={{
300
+ padding: 'var(--sv-space-6)',
301
+ background: 'var(--sv-color-surface)',
302
+ minHeight: '100vh',
303
+ fontFamily: ff,
304
+ maxWidth: 760,
305
+ }}
306
+ >
307
+ <div style={{ marginBottom: 'var(--sv-space-8)' }}>
308
+ <Heading level={1}>Interaction Hooks</Heading>
309
+ <Body>
310
+ Touch gesture handling, carried once in the design system so no plugin has to rediscover
311
+ the failure modes. Per the DS-first principle, build gesture handling here — not in a
312
+ plugin. See{' '}
313
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)', fontSize: '0.8em' }}>
314
+ docs/design-system.md
315
+ </code>{' '}
316
+ for the full written reference.
317
+ </Body>
318
+ </div>
319
+
320
+ <section style={{ marginBottom: 'var(--sv-space-10)' }}>
321
+ <SectionHeader
322
+ title="useLongPress"
323
+ subtitle="Touch-and-hold with movement tolerance, pointercancel handling, OS-callout suppression, and time-boxed click suppression."
324
+ />
325
+ <Card padding="md">
326
+ <LongPressDemo />
327
+ </Card>
328
+ <CodeBlock>{`const longPress = useLongPress({ onLongPress: () => setBulkMode(true) });
329
+ <div {...longPress}>Hold to select</div>`}</CodeBlock>
330
+ </section>
331
+
332
+ <section style={{ marginBottom: 'var(--sv-space-10)' }}>
333
+ <SectionHeader
334
+ title="useDoubleTapHandler / useSingleOrDoubleTap"
335
+ subtitle="Desktop double-click reports e.detail === 2 natively; touch needs timing-based detection instead."
336
+ />
337
+ <Card padding="md">
338
+ <DoubleTapDemo />
339
+ </Card>
340
+ <Callout type="tip">
341
+ Use <code style={{ fontFamily: 'var(--sv-font-family-mono)' }}>useDoubleTapHandler</code>{' '}
342
+ when the single tap has nothing to cancel. Use{' '}
343
+ <code style={{ fontFamily: 'var(--sv-font-family-mono)' }}>useSingleOrDoubleTap</code>{' '}
344
+ only when the single tap has a default action (e.g. navigation) a following double-tap
345
+ must be able to preempt — every single tap through it pays the ~350ms wait.
346
+ </Callout>
347
+ </section>
348
+
349
+ <section style={{ marginBottom: 'var(--sv-space-10)' }}>
350
+ <SectionHeader
351
+ title="useIsMobile"
352
+ subtitle="SSR-safe viewport check against the platform's single documented breakpoint (768px)."
353
+ />
354
+ <Card padding="md">
355
+ <IsMobileDemo />
356
+ </Card>
357
+ <CodeBlock>{`import { useIsMobile, MOBILE_BREAKPOINT_PX } from '@sovereignfs/ui';
358
+
359
+ const isMobile = useIsMobile(); // defaults to MOBILE_BREAKPOINT_PX (768)`}</CodeBlock>
360
+ </section>
361
+ </div>
362
+ );
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+
367
+ const meta = {
368
+ title: 'Overview/Interaction Hooks',
369
+ component: InteractionHooksDoc,
370
+ parameters: {
371
+ layout: 'fullscreen',
372
+ docs: {
373
+ description: {
374
+ component:
375
+ 'Reference and live demos for the touch-gesture hooks exported from @sovereignfs/ui. Long-press requires real touch input to trigger — use the devtools device toolbar or a physical device.',
376
+ },
377
+ },
378
+ },
379
+ } satisfies Meta<typeof InteractionHooksDoc>;
380
+
381
+ export default meta;
382
+ type Story = StoryObj<typeof meta>;
383
+
384
+ export const Default: Story = {};
385
+
386
+ export const MobileView: Story = {
387
+ parameters: { viewport: { defaultViewport: 'mobile' } },
388
+ };
@@ -405,11 +405,11 @@ function MobilePatternsDoc() {
405
405
  gap: 'var(--sv-space-3)',
406
406
  }}
407
407
  >
408
- <FormField label="Display name" htmlFor="mp-name">
409
- <Input id="mp-name" placeholder="Your name" />
408
+ <FormField label="Display name" id="mp-name">
409
+ {(field) => <Input {...field} placeholder="Your name" />}
410
410
  </FormField>
411
- <FormField label="Email" htmlFor="mp-email">
412
- <Input id="mp-email" type="email" placeholder="you@example.com" />
411
+ <FormField label="Email" id="mp-email">
412
+ {(field) => <Input {...field} type="email" placeholder="you@example.com" />}
413
413
  </FormField>
414
414
  <Button type="submit">Save changes</Button>
415
415
  </div>
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Textarea } from '../components/Textarea/Textarea';
3
+
4
+ // Wrap in a label so a11y checks pass (Textarea is a bare <textarea> — no
5
+ // built-in label association).
6
+ function Labeled({ label, ...props }: React.ComponentProps<typeof Textarea> & { label: string }) {
7
+ return (
8
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: 280 }}>
9
+ <label
10
+ htmlFor="sb-textarea"
11
+ style={{ fontSize: 14, color: 'var(--sv-color-text-primary)', fontFamily: 'system-ui' }}
12
+ >
13
+ {label}
14
+ </label>
15
+ <Textarea id="sb-textarea" {...props} />
16
+ </div>
17
+ );
18
+ }
19
+
20
+ const meta = {
21
+ title: 'Components/Textarea',
22
+ component: Textarea,
23
+ parameters: {
24
+ layout: 'centered',
25
+ docs: {
26
+ description: {
27
+ component:
28
+ 'The primitive multi-line text field. RSC-safe; forwards all native `<textarea>` props. No label is built-in — always pair with a `<label>` (or `FormField`) for accessibility.',
29
+ },
30
+ },
31
+ },
32
+ } satisfies Meta<typeof Textarea>;
33
+
34
+ export default meta;
35
+ type Story = StoryObj<typeof meta>;
36
+
37
+ export const Default: Story = {
38
+ render: (_args) => <Labeled label="Description" placeholder="Add a description…" />,
39
+ };
40
+
41
+ export const WithValue: Story = {
42
+ render: (_args) => <Labeled label="Notes" defaultValue="Follow up next week." />,
43
+ };
44
+
45
+ export const CustomRows: Story = {
46
+ render: (_args) => <Labeled label="Message" rows={8} placeholder="Write your message…" />,
47
+ };
48
+
49
+ export const Disabled: Story = {
50
+ render: (_args) => <Labeled label="Read-only field" defaultValue="Cannot be changed" disabled />,
51
+ };
@@ -1,3 +1,4 @@
1
+ import { useEffect, useState } from 'react';
1
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
3
 
3
4
  // ---------------------------------------------------------------------------
@@ -19,16 +20,21 @@ const SEMANTIC_COLORS = [
19
20
  '--sv-color-border-strong',
20
21
  '--sv-color-accent',
21
22
  '--sv-color-accent-hover',
23
+ '--sv-color-accent-subtle',
22
24
  '--sv-color-focus-ring',
23
25
  '--sv-color-error-surface',
24
26
  '--sv-color-error-text',
25
27
  '--sv-color-error-border',
28
+ '--sv-color-error-solid',
26
29
  '--sv-color-warning-surface',
27
30
  '--sv-color-warning-text',
28
31
  '--sv-color-warning-border',
29
32
  '--sv-color-success-surface',
30
33
  '--sv-color-success-text',
31
34
  '--sv-color-success-border',
35
+ '--sv-color-success-solid',
36
+ '--sv-color-text-on-error',
37
+ '--sv-color-text-on-success',
32
38
  '--sv-color-info-surface',
33
39
  '--sv-color-info-text',
34
40
  '--sv-color-info-border',
@@ -57,7 +63,12 @@ const RADIUS_TOKENS = [
57
63
  '--sv-radius-3xl',
58
64
  '--sv-radius-full',
59
65
  ];
60
- const ICON_SIZE_TOKENS = ['--sv-icon-size-sm', '--sv-icon-size-md', '--sv-icon-size-lg'];
66
+ const ICON_SIZE_TOKENS = [
67
+ '--sv-icon-size-xs',
68
+ '--sv-icon-size-sm',
69
+ '--sv-icon-size-md',
70
+ '--sv-icon-size-lg',
71
+ ];
61
72
 
62
73
  const FONT_SIZE_TOKENS = [
63
74
  '--sv-font-size-label',
@@ -82,6 +93,32 @@ const SHADOW_TOKENS = [
82
93
  '--sv-shadow-hover',
83
94
  '--sv-shadow-popover',
84
95
  '--sv-shadow-overlay',
96
+ '--sv-shadow-control',
97
+ ];
98
+
99
+ // Realistic duration+easing pairings, not every combination — matches how
100
+ // Dialog/Drawer actually use these tokens (see motion.ts).
101
+ const MOTION_PAIRS: { durationToken: string; easeToken: string; use: string }[] = [
102
+ {
103
+ durationToken: '--sv-motion-duration-fast',
104
+ easeToken: '--sv-motion-ease-out',
105
+ use: 'micro-interaction',
106
+ },
107
+ {
108
+ durationToken: '--sv-motion-duration-base',
109
+ easeToken: '--sv-motion-ease-out',
110
+ use: 'Dialog / Drawer entrance',
111
+ },
112
+ {
113
+ durationToken: '--sv-motion-duration-slow',
114
+ easeToken: '--sv-motion-ease-in-out',
115
+ use: 'larger surface / longer travel',
116
+ },
117
+ {
118
+ durationToken: '--sv-motion-duration-base',
119
+ easeToken: '--sv-motion-ease-spring',
120
+ use: 'emphasis (use sparingly)',
121
+ },
85
122
  ];
86
123
 
87
124
  // ---------------------------------------------------------------------------
@@ -152,6 +189,49 @@ function ScaleRow({
152
189
  );
153
190
  }
154
191
 
192
+ // Auto-loops so the duration/easing is visible without needing to interact —
193
+ // same technique Dialog/Drawer use for their own open/close transition
194
+ // (see motion.ts), just driven by a timer instead of an `open` prop.
195
+ function MotionRow({ durationToken, easeToken, use }: (typeof MOTION_PAIRS)[number]) {
196
+ const [toggled, setToggled] = useState(false);
197
+ useEffect(() => {
198
+ const id = setInterval(() => setToggled((v) => !v), 900);
199
+ return () => clearInterval(id);
200
+ }, []);
201
+ return (
202
+ <div style={row}>
203
+ <div
204
+ style={{
205
+ position: 'relative',
206
+ width: 120,
207
+ height: 24,
208
+ background: 'var(--sv-color-surface-sunken)',
209
+ borderRadius: 4,
210
+ flexShrink: 0,
211
+ }}
212
+ >
213
+ <div
214
+ style={{
215
+ position: 'absolute',
216
+ top: 2,
217
+ left: 2,
218
+ width: 20,
219
+ height: 20,
220
+ borderRadius: '50%',
221
+ background: 'var(--sv-color-accent)',
222
+ transform: `translateX(${toggled ? 96 : 0}px)`,
223
+ transition: `transform var(${durationToken}) var(${easeToken})`,
224
+ }}
225
+ />
226
+ </div>
227
+ <span style={label}>
228
+ {durationToken} · {easeToken}
229
+ </span>
230
+ <span style={value}>{use}</span>
231
+ </div>
232
+ );
233
+ }
234
+
155
235
  function Section({ title, children }: { title: string; children: React.ReactNode }) {
156
236
  return (
157
237
  <section style={{ marginBottom: 40 }}>
@@ -329,6 +409,12 @@ function TokenGalleryComponent() {
329
409
  />
330
410
  ))}
331
411
  </Section>
412
+
413
+ <Section title="Motion">
414
+ {MOTION_PAIRS.map((pair) => (
415
+ <MotionRow key={`${pair.durationToken}-${pair.easeToken}`} {...pair} />
416
+ ))}
417
+ </Section>
332
418
  </div>
333
419
  );
334
420
  }
@@ -78,6 +78,7 @@
78
78
  --sv-font-weight-bold: 700;
79
79
 
80
80
  /* Icon sizes — scale tokens used directly by the Icon component */
81
+ --sv-icon-size-xs: 0.75rem; /* 12px — inline metadata (e.g. a due-date badge) */
81
82
  --sv-icon-size-sm: 1rem; /* 16px — inline with body text */
82
83
  --sv-icon-size-md: 1.25rem; /* 20px — standard affordance */
83
84
  --sv-icon-size-lg: 1.5rem; /* 24px — prominent / standalone */
@@ -96,4 +97,22 @@
96
97
  touch without a pointing device. Applied to icon-only controls on mobile
97
98
  (avatar, footer button, Drawer nav items). */
98
99
  --sv-touch-target-min: 44px;
100
+
101
+ /* Motion scale — durations and easings for overlay enter/exit transitions
102
+ (Dialog, Drawer, and their Phase-B successors: Sheet, ConfirmDialog,
103
+ Menu). Theme-stable like the other scale tokens above — dark mode does
104
+ not change motion, so there is no semantic tier. Components must pair
105
+ every transition with `@media (prefers-reduced-motion: reduce)` collapsing
106
+ it to near-instant; the durations here are not a substitute for that. */
107
+ --sv-motion-duration-fast: 150ms; /* micro-interactions — hover/press feedback */
108
+ --sv-motion-duration-base: 250ms; /* default overlay enter/exit (Dialog, Drawer) */
109
+ --sv-motion-duration-slow: 350ms; /* larger surfaces / bigger travel distance */
110
+ --sv-motion-ease-out: cubic-bezier(0, 0, 0.2, 1); /* entrances — fast start, gentle settle */
111
+ --sv-motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* state changes with no fixed direction */
112
+ --sv-motion-ease-spring: cubic-bezier(
113
+ 0.34,
114
+ 1.56,
115
+ 0.64,
116
+ 1
117
+ ); /* slight overshoot — playful emphasis, use sparingly */
99
118
  }
@@ -25,6 +25,10 @@
25
25
 
26
26
  --sv-color-accent: var(--sv-grey-900);
27
27
  --sv-color-accent-hover: var(--sv-grey-700);
28
+ /* Derived from --sv-color-accent via color-mix, so it re-themes automatically —
29
+ no separate dark-mode override needed. Tinted background for badges/chips
30
+ paired with --sv-color-accent text. */
31
+ --sv-color-accent-subtle: color-mix(in srgb, var(--sv-color-accent) 12%, transparent);
28
32
 
29
33
  --sv-color-focus-ring: var(--sv-grey-900);
30
34
 
@@ -41,6 +45,19 @@
41
45
  --sv-color-success-text: var(--sv-green-800);
42
46
  --sv-color-success-border: var(--sv-green-200);
43
47
 
48
+ /* Status solids — a saturated fill for status affordances that need to
49
+ read as a filled action button rather than a tinted surface (e.g. a
50
+ mobile swipe-to-reveal Delete/Done action). Paired with
51
+ --sv-color-text-on-error/-success (white) for the icon/label on top.
52
+ Intentionally NOT overridden under [data-theme='dark'] below — like
53
+ --sv-color-accent-subtle, a solid action fill reads the same regardless
54
+ of overall page theme; it's the surrounding surface that inverts, not
55
+ this fixed-contrast pairing. */
56
+ --sv-color-error-solid: var(--sv-red-700);
57
+ --sv-color-success-solid: var(--sv-green-800);
58
+ --sv-color-text-on-error: var(--sv-white);
59
+ --sv-color-text-on-success: var(--sv-white);
60
+
44
61
  /* Info surfaces — blue palette. Use for informational banners and badges.
45
62
  Never hardcode colour hex in components — reference these tokens. */
46
63
  --sv-color-info-surface: var(--sv-blue-100);
@@ -56,6 +73,7 @@
56
73
  --sv-shadow-popover:
57
74
  0 4px 16px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08); /* e2 — floating panels */
58
75
  --sv-shadow-overlay: 0 10px 38px rgba(0, 0, 0, 0.18), 0 6px 12px rgba(0, 0, 0, 0.12);
76
+ --sv-shadow-control: 0 1px 3px rgba(0, 0, 0, 0.2); /* small interactive-control shadows, e.g. Toggle thumb */
59
77
 
60
78
  /*
61
79
  * Instance identity tokens (RFC 0027 / RFC 0032 rename). Distinct namespace from --sv-color-*:
@@ -108,4 +126,5 @@
108
126
  --sv-shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.3);
109
127
  --sv-shadow-popover: 0 4px 16px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
110
128
  --sv-shadow-overlay: 0 10px 38px rgba(0, 0, 0, 0.6), 0 6px 12px rgba(0, 0, 0, 0.5);
129
+ --sv-shadow-control: 0 1px 3px rgba(0, 0, 0, 0.5);
111
130
  }
@@ -1,19 +0,0 @@
1
- import type { SVGProps } from 'react';
2
-
3
- export function TerminalIcon(props: SVGProps<SVGSVGElement>) {
4
- return (
5
- <svg
6
- xmlns="http://www.w3.org/2000/svg"
7
- viewBox="0 0 24 24"
8
- fill="none"
9
- stroke="currentColor"
10
- strokeWidth="2"
11
- strokeLinecap="round"
12
- strokeLinejoin="round"
13
- {...props}
14
- >
15
- <polyline points="4 17 10 11 4 5" />
16
- <line x1="12" x2="20" y1="19" y2="19" />
17
- </svg>
18
- );
19
- }