@nim.zone/ui 0.6.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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +681 -0
  3. package/dist/components/admin-shell.d.ts +121 -0
  4. package/dist/components/app-shell.d.ts +23 -0
  5. package/dist/components/auth-screen.d.ts +33 -0
  6. package/dist/components/avatar.d.ts +10 -0
  7. package/dist/components/badge.d.ts +14 -0
  8. package/dist/components/banner.d.ts +11 -0
  9. package/dist/components/breadcrumb.d.ts +12 -0
  10. package/dist/components/button.d.ts +21 -0
  11. package/dist/components/card.d.ts +15 -0
  12. package/dist/components/chat-composer.d.ts +52 -0
  13. package/dist/components/chat.d.ts +77 -0
  14. package/dist/components/checkout.d.ts +79 -0
  15. package/dist/components/choice.d.ts +15 -0
  16. package/dist/components/combobox.d.ts +31 -0
  17. package/dist/components/date-field.d.ts +58 -0
  18. package/dist/components/dialog.d.ts +20 -0
  19. package/dist/components/empty-state.d.ts +9 -0
  20. package/dist/components/feedback.d.ts +18 -0
  21. package/dist/components/field.d.ts +47 -0
  22. package/dist/components/icon-button.d.ts +12 -0
  23. package/dist/components/icon.d.ts +72 -0
  24. package/dist/components/layout.d.ts +13 -0
  25. package/dist/components/list.d.ts +16 -0
  26. package/dist/components/menu.d.ts +56 -0
  27. package/dist/components/onboarding.d.ts +50 -0
  28. package/dist/components/otp-input.d.ts +29 -0
  29. package/dist/components/pagination.d.ts +12 -0
  30. package/dist/components/password-field.d.ts +33 -0
  31. package/dist/components/phone-field.d.ts +44 -0
  32. package/dist/components/plan-card.d.ts +45 -0
  33. package/dist/components/plan-picker.d.ts +52 -0
  34. package/dist/components/profile-header.d.ts +47 -0
  35. package/dist/components/profile-screen.d.ts +44 -0
  36. package/dist/components/resource-meter.d.ts +22 -0
  37. package/dist/components/section-header.d.ts +8 -0
  38. package/dist/components/segmented.d.ts +18 -0
  39. package/dist/components/sheet.d.ts +18 -0
  40. package/dist/components/sign-in-flow.d.ts +60 -0
  41. package/dist/components/slider.d.ts +10 -0
  42. package/dist/components/stat.d.ts +10 -0
  43. package/dist/components/stepper.d.ts +17 -0
  44. package/dist/components/tab-bar.d.ts +40 -0
  45. package/dist/components/table.d.ts +33 -0
  46. package/dist/components/tabs.d.ts +23 -0
  47. package/dist/components/task-progress.d.ts +38 -0
  48. package/dist/components/theme.d.ts +49 -0
  49. package/dist/components/toast.d.ts +17 -0
  50. package/dist/components/tooltip.d.ts +18 -0
  51. package/dist/components/typography.d.ts +18 -0
  52. package/dist/components/wizard.d.ts +70 -0
  53. package/dist/index.d.ts +104 -0
  54. package/dist/lib/calendars.d.ts +81 -0
  55. package/dist/lib/cn.d.ts +7 -0
  56. package/dist/lib/countries.d.ts +33 -0
  57. package/dist/lib/use-anchor.d.ts +22 -0
  58. package/dist/nim.css +1 -0
  59. package/dist/nim.js +3266 -0
  60. package/package.json +83 -0
  61. package/src/components/admin-shell.tsx +338 -0
  62. package/src/components/app-shell.tsx +36 -0
  63. package/src/components/auth-screen.tsx +71 -0
  64. package/src/components/avatar.tsx +32 -0
  65. package/src/components/badge.tsx +44 -0
  66. package/src/components/banner.tsx +47 -0
  67. package/src/components/breadcrumb.tsx +45 -0
  68. package/src/components/button.tsx +64 -0
  69. package/src/components/card.tsx +46 -0
  70. package/src/components/chat-composer.tsx +331 -0
  71. package/src/components/chat.tsx +352 -0
  72. package/src/components/checkout.tsx +173 -0
  73. package/src/components/choice.tsx +46 -0
  74. package/src/components/combobox.tsx +157 -0
  75. package/src/components/date-field.tsx +394 -0
  76. package/src/components/dialog.tsx +77 -0
  77. package/src/components/empty-state.tsx +23 -0
  78. package/src/components/feedback.tsx +61 -0
  79. package/src/components/field.tsx +177 -0
  80. package/src/components/icon-button.tsx +35 -0
  81. package/src/components/icon.tsx +167 -0
  82. package/src/components/layout.tsx +38 -0
  83. package/src/components/list.tsx +78 -0
  84. package/src/components/menu.tsx +172 -0
  85. package/src/components/onboarding.tsx +157 -0
  86. package/src/components/otp-input.tsx +144 -0
  87. package/src/components/pagination.tsx +86 -0
  88. package/src/components/password-field.tsx +112 -0
  89. package/src/components/phone-field.tsx +242 -0
  90. package/src/components/plan-card.tsx +124 -0
  91. package/src/components/plan-picker.tsx +144 -0
  92. package/src/components/profile-header.tsx +134 -0
  93. package/src/components/profile-screen.tsx +104 -0
  94. package/src/components/resource-meter.tsx +60 -0
  95. package/src/components/section-header.tsx +31 -0
  96. package/src/components/segmented.tsx +53 -0
  97. package/src/components/sheet.tsx +74 -0
  98. package/src/components/sign-in-flow.tsx +322 -0
  99. package/src/components/slider.tsx +50 -0
  100. package/src/components/stat.tsx +30 -0
  101. package/src/components/stepper.tsx +77 -0
  102. package/src/components/tab-bar.tsx +82 -0
  103. package/src/components/table.tsx +81 -0
  104. package/src/components/tabs.tsx +72 -0
  105. package/src/components/task-progress.tsx +111 -0
  106. package/src/components/theme.tsx +112 -0
  107. package/src/components/toast.tsx +87 -0
  108. package/src/components/tooltip.tsx +30 -0
  109. package/src/components/typography.tsx +65 -0
  110. package/src/components/wizard.tsx +192 -0
  111. package/src/index.ts +216 -0
  112. package/src/lib/calendars.ts +227 -0
  113. package/src/lib/cn.ts +8 -0
  114. package/src/lib/countries.ts +110 -0
  115. package/src/lib/use-anchor.ts +92 -0
  116. package/src/theme/colorways/coral.css +62 -0
  117. package/src/theme/colorways/oxblood.css +23 -0
  118. package/src/theme/colorways/paper.css +68 -0
  119. package/src/theme/colorways/teal.css +56 -0
  120. package/src/theme/colorways/vermilion.css +21 -0
  121. package/src/theme/components.css +4606 -0
  122. package/src/theme/contract.css +204 -0
  123. package/src/theme/fonts.css +67 -0
  124. package/src/theme/index.css +33 -0
  125. package/src/theme/persian.css +47 -0
  126. package/src/theme/reset.css +118 -0
  127. package/src/theme/styles/ledger.css +106 -0
  128. package/src/theme/styles/vlora.css +91 -0
  129. package/src/vite-env.d.ts +1 -0
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@nim.zone/ui",
3
+ "version": "0.6.0",
4
+ "type": "module",
5
+ "description": "nim — the front-end design system and UI kit shared across nim products.",
6
+ "keywords": [
7
+ "design-system",
8
+ "ui-kit",
9
+ "react",
10
+ "components",
11
+ "tokens",
12
+ "rtl",
13
+ "persian",
14
+ "tailwind",
15
+ "accessible"
16
+ ],
17
+ "author": "Nima Sarayan",
18
+ "license": "MIT",
19
+ "homepage": "https://nim.zone/uikit",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/nimasrn/nim.git",
23
+ "directory": "nim-ui"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/nimasrn/nim/issues"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "sideEffects": [
32
+ "*.css"
33
+ ],
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/nim.js"
38
+ },
39
+ "./styles.css": "./dist/nim.css",
40
+ "./src/theme/index.css": "./src/theme/index.css",
41
+ "./fonts.css": "./src/theme/fonts.css"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "src",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "dev": "vite",
51
+ "build": "tsc -b && vite build --mode lib",
52
+ "build:docs": "tsc -b && vite build",
53
+ "preview": "vite preview",
54
+ "lint": "eslint . --max-warnings=0",
55
+ "typecheck": "tsc -b --pretty",
56
+ "prepublishOnly": "npm run build"
57
+ },
58
+ "peerDependencies": {
59
+ "react": ">=18",
60
+ "react-dom": ">=18"
61
+ },
62
+ "dependencies": {
63
+ "lucide-react": "^0.544.0"
64
+ },
65
+ "devDependencies": {
66
+ "@eslint/js": "^9.39.4",
67
+ "@tailwindcss/vite": "^4.2.2",
68
+ "@types/node": "^26.2.0",
69
+ "@types/react": "^18.3.18",
70
+ "@types/react-dom": "^18.3.5",
71
+ "@vitejs/plugin-react-swc": "^3.9.0",
72
+ "eslint": "^9.39.4",
73
+ "eslint-plugin-react-hooks": "^7.1.1",
74
+ "globals": "^16.4.0",
75
+ "react": "^18.3.1",
76
+ "react-dom": "^18.3.1",
77
+ "tailwindcss": "^4.2.2",
78
+ "typescript": "~5.9.3",
79
+ "typescript-eslint": "^8.59.3",
80
+ "vite": "^6.3.5",
81
+ "vite-plugin-dts": "^4.5.4"
82
+ }
83
+ }
@@ -0,0 +1,338 @@
1
+ import { useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { Icon, type IconName } from '@/components/icon'
4
+ import { IconButton } from '@/components/icon-button'
5
+ import { cn } from '@/lib/cn'
6
+
7
+ export interface AdminNavItem {
8
+ href?: string
9
+ icon?: IconName
10
+ key: string
11
+ label: ReactNode
12
+ onSelect?: () => void
13
+ }
14
+
15
+ export interface AdminNavGroup {
16
+ icon?: IconName
17
+ items: AdminNavItem[]
18
+ key: string
19
+ label: ReactNode
20
+ }
21
+
22
+ export interface AdminShellProps {
23
+ /** Brand mark at the top of the sidebar. */
24
+ brand?: ReactNode
25
+ children: ReactNode
26
+ className?: string
27
+ /** Under the nav: session state, build version — the things an operator
28
+ checks before believing a screen. */
29
+ sidebarFooter?: ReactNode
30
+ groups: AdminNavGroup[]
31
+ labels?: { menu: string; nav: string; close: string }
32
+ /** `key` of the active item. */
33
+ value: string
34
+ /** Search field, status pills, the signed-in operator. */
35
+ toolbar?: ReactNode
36
+ title?: ReactNode
37
+ }
38
+
39
+ const DEFAULT_LABELS = {
40
+ close: 'Close menu',
41
+ menu: 'Open menu',
42
+ nav: 'Admin navigation',
43
+ }
44
+
45
+ /**
46
+ * The console layout: a grouped sidebar, a topbar, and one scrolling workspace.
47
+ *
48
+ * The counterpart to `AppShell`, and deliberately a different component rather
49
+ * than a `variant`: an operator console is a desktop-first, two-column, deep
50
+ * hierarchy, and a phone app is a single column with five destinations. Sharing
51
+ * one component would mean every screen carrying the other's assumptions.
52
+ *
53
+ * Below the layout's breakpoint the same sidebar becomes a drawer — the same
54
+ * markup, not a second nav to keep in sync, which is how the two drift apart.
55
+ */
56
+ export function AdminShell({
57
+ brand,
58
+ children,
59
+ className,
60
+ groups,
61
+ labels,
62
+ sidebarFooter,
63
+ title,
64
+ toolbar,
65
+ value,
66
+ }: AdminShellProps) {
67
+ const text = { ...DEFAULT_LABELS, ...labels }
68
+ const [open, setOpen] = useState(false)
69
+
70
+ const nav = (
71
+ <nav aria-label={text.nav} className="nim-admin__nav">
72
+ {groups.map((group) => (
73
+ <div className="nim-admin__group" key={group.key}>
74
+ <p className="nim-admin__group-label">
75
+ {group.icon ? <Icon name={group.icon} size="xs" /> : null}
76
+ {group.label}
77
+ </p>
78
+ {group.items.map((item) => {
79
+ const active = item.key === value
80
+ const content = (
81
+ <>
82
+ {item.icon ? <Icon name={item.icon} size="sm" /> : null}
83
+ <span>{item.label}</span>
84
+ </>
85
+ )
86
+ const props = {
87
+ 'aria-current': active ? ('page' as const) : undefined,
88
+ className: 'nim-admin__link',
89
+ 'data-active': active ? 'true' : undefined,
90
+ onClick: () => {
91
+ item.onSelect?.()
92
+ setOpen(false)
93
+ },
94
+ }
95
+ return item.href ? (
96
+ <a href={item.href} key={item.key} {...props}>
97
+ {content}
98
+ </a>
99
+ ) : (
100
+ <button key={item.key} type="button" {...props}>
101
+ {content}
102
+ </button>
103
+ )
104
+ })}
105
+ </div>
106
+ ))}
107
+ </nav>
108
+ )
109
+
110
+ return (
111
+ <div className={cn('nim-admin', className)} data-drawer={open ? 'open' : undefined}>
112
+ <aside className="nim-admin__sidebar">
113
+ {brand ? <div className="nim-admin__brand">{brand}</div> : null}
114
+ {nav}
115
+ {sidebarFooter ? <div className="nim-admin__sidebar-foot">{sidebarFooter}</div> : null}
116
+ </aside>
117
+
118
+ {/* The drawer is the sidebar again, not a second navigation: one source
119
+ for the items, so the two cannot disagree. */}
120
+ <div className="nim-admin__drawer" hidden={!open}>
121
+ <div className="nim-admin__scrim" onClick={() => setOpen(false)} />
122
+ <div className="nim-admin__drawer-panel">
123
+ <div className="nim-admin__drawer-head">
124
+ {brand}
125
+ <IconButton label={text.close} name="close" onClick={() => setOpen(false)} size="sm" />
126
+ </div>
127
+ {nav}
128
+ </div>
129
+ </div>
130
+
131
+ <div className="nim-admin__workspace">
132
+ <header className="nim-admin__topbar">
133
+ <IconButton
134
+ aria-expanded={open}
135
+ className="nim-admin__menu"
136
+ label={text.menu}
137
+ name="menu"
138
+ onClick={() => setOpen(true)}
139
+ size="sm"
140
+ />
141
+ {title ? <h1 className="nim-admin__title">{title}</h1> : null}
142
+ {toolbar ? <div className="nim-admin__toolbar">{toolbar}</div> : null}
143
+ </header>
144
+ <main className="nim-admin__main">{children}</main>
145
+ </div>
146
+ </div>
147
+ )
148
+ }
149
+
150
+ export interface DetailHeaderProps {
151
+ /** Buttons for this record: approve, retry, export. */
152
+ actions?: ReactNode
153
+ back?: { href?: string; label: string; onClick?: () => void }
154
+ className?: string
155
+ /** Status badge, owner, id — the facts that qualify the title. */
156
+ meta?: ReactNode
157
+ status?: ReactNode
158
+ subtitle?: ReactNode
159
+ title: ReactNode
160
+ }
161
+
162
+ /**
163
+ * The top of a record: where it sits, what it is, and what can be done to it.
164
+ *
165
+ * The actions live here rather than at the bottom of the page because an
166
+ * operator working a queue acts on the record without reading all of it, and a
167
+ * button below a thousand-row table is a button nobody finds.
168
+ */
169
+ export function DetailHeader({
170
+ actions,
171
+ back,
172
+ className,
173
+ meta,
174
+ status,
175
+ subtitle,
176
+ title,
177
+ }: DetailHeaderProps) {
178
+ return (
179
+ <header className={cn('nim-detail-header', className)}>
180
+ {back ? (
181
+ back.href ? (
182
+ <a className="nim-detail-header__back" href={back.href}>
183
+ <Icon name="chevron-back" size="sm" />
184
+ {back.label}
185
+ </a>
186
+ ) : (
187
+ <button className="nim-detail-header__back" onClick={back.onClick} type="button">
188
+ <Icon name="chevron-back" size="sm" />
189
+ {back.label}
190
+ </button>
191
+ )
192
+ ) : null}
193
+
194
+ <div className="nim-detail-header__row">
195
+ <div className="nim-detail-header__text">
196
+ {/* The status sits beside the heading, not inside it: a badge folded
197
+ into an <h1> becomes part of the heading's accessible name, and
198
+ "Payment #48210 Awaiting review" is not what the record is
199
+ called. */}
200
+ <div className="nim-detail-header__headline">
201
+ <h1 className="nim-detail-header__title">{title}</h1>
202
+ {status ? <span className="nim-detail-header__status">{status}</span> : null}
203
+ </div>
204
+ {subtitle ? <p className="nim-detail-header__subtitle">{subtitle}</p> : null}
205
+ {meta ? <p className="nim-detail-header__meta">{meta}</p> : null}
206
+ </div>
207
+ {actions ? <div className="nim-detail-header__actions">{actions}</div> : null}
208
+ </div>
209
+ </header>
210
+ )
211
+ }
212
+
213
+ export interface FilterChip {
214
+ key: string
215
+ label: ReactNode
216
+ onRemove: () => void
217
+ /** The value the filter is set to, shown after the label. */
218
+ value?: ReactNode
219
+ }
220
+
221
+ export interface FilterChipsProps {
222
+ chips: FilterChip[]
223
+ className?: string
224
+ clearLabel?: string
225
+ labels?: { remove: (label: string) => string; toolbar: string }
226
+ onClearAll?: () => void
227
+ }
228
+
229
+ /**
230
+ * The filters currently narrowing a table, each removable.
231
+ *
232
+ * It renders nothing when there are none — an empty toolbar reserving space
233
+ * for filters nobody set is the reason these strips feel like chrome. Every
234
+ * chip states what it removes in its accessible name, because "remove" ten
235
+ * times over is no name at all.
236
+ */
237
+ export function FilterChips({
238
+ chips,
239
+ className,
240
+ clearLabel,
241
+ labels,
242
+ onClearAll,
243
+ }: FilterChipsProps) {
244
+ if (chips.length === 0) return null
245
+ const text = {
246
+ remove: (label: string) => `Remove filter ${label}`,
247
+ toolbar: 'Active filters',
248
+ ...labels,
249
+ }
250
+
251
+ return (
252
+ <div aria-label={text.toolbar} className={cn('nim-filter-chips', className)} role="toolbar">
253
+ {chips.map((chip) => (
254
+ <span className="nim-filter-chip" key={chip.key}>
255
+ <span className="nim-filter-chip__label">
256
+ {chip.label}
257
+ {chip.value !== undefined ? <>: {chip.value}</> : null}
258
+ </span>
259
+ <button
260
+ aria-label={text.remove(typeof chip.label === 'string' ? chip.label : chip.key)}
261
+ className="nim-filter-chip__remove"
262
+ onClick={chip.onRemove}
263
+ type="button"
264
+ >
265
+ <Icon name="close" size="xs" />
266
+ </button>
267
+ </span>
268
+ ))}
269
+ {onClearAll && clearLabel ? (
270
+ <button className="nim-filter-chips__clear" onClick={onClearAll} type="button">
271
+ {clearLabel}
272
+ </button>
273
+ ) : null}
274
+ </div>
275
+ )
276
+ }
277
+
278
+ export interface ActivityEvent {
279
+ /** What happened, in the product's own words. */
280
+ action: ReactNode
281
+ actor?: ReactNode
282
+ /** ISO timestamp. Formatted here, in the viewer's locale. */
283
+ at?: string
284
+ icon?: IconName
285
+ id: string
286
+ target?: ReactNode
287
+ tone?: 'accent' | 'danger' | 'default' | 'success' | 'warning'
288
+ }
289
+
290
+ export interface ActivityFeedProps {
291
+ className?: string
292
+ empty?: ReactNode
293
+ events: ActivityEvent[]
294
+ locale?: string
295
+ }
296
+
297
+ /**
298
+ * Who did what, most recent first.
299
+ *
300
+ * Timestamps are absolute rather than "3 hours ago": an audit trail is read to
301
+ * reconstruct a sequence, and a relative time that keeps moving is exactly
302
+ * what you cannot compare two of.
303
+ */
304
+ export function ActivityFeed({ className, empty, events, locale }: ActivityFeedProps) {
305
+ const when = new Intl.DateTimeFormat(locale, {
306
+ day: 'numeric',
307
+ hour: '2-digit',
308
+ minute: '2-digit',
309
+ month: 'short',
310
+ })
311
+
312
+ if (events.length === 0) {
313
+ return <div className={cn('nim-activity', className)}>{empty}</div>
314
+ }
315
+
316
+ return (
317
+ <ol className={cn('nim-activity', className)}>
318
+ {events.map((event) => (
319
+ <li className="nim-activity__item" data-tone={event.tone} key={event.id}>
320
+ <span className="nim-activity__marker">
321
+ {event.icon ? <Icon name={event.icon} size="xs" /> : null}
322
+ </span>
323
+ <div className="nim-activity__body">
324
+ <p className="nim-activity__action">
325
+ {event.actor ? <strong>{event.actor}</strong> : null} {event.action}{' '}
326
+ {event.target ? <span className="nim-activity__target">{event.target}</span> : null}
327
+ </p>
328
+ {event.at ? (
329
+ <time className="nim-activity__time" dateTime={event.at}>
330
+ {when.format(new Date(event.at))}
331
+ </time>
332
+ ) : null}
333
+ </div>
334
+ </li>
335
+ ))}
336
+ </ol>
337
+ )
338
+ }
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from 'react'
2
+ import { TabBar, type TabBarProps } from '@/components/tab-bar'
3
+ import { cn } from '@/lib/cn'
4
+
5
+ export interface AppShellProps {
6
+ children: ReactNode
7
+ className?: string
8
+ /** Sticky top row: a title, a back control, an action. Optional — many
9
+ screens carry their own header inside the content instead. */
10
+ header?: ReactNode
11
+ /** The bottom navigation. Omit it on a screen deeper in the stack: a tab bar
12
+ that stays visible under a detail page invites the viewer to leave. */
13
+ tabs?: TabBarProps
14
+ }
15
+
16
+ /**
17
+ * The frame an app lives in: a sticky header, one scrolling region, and the
18
+ * tab bar.
19
+ *
20
+ * The scroll container is here and nowhere else, which is what makes scroll
21
+ * restoration, pull-to-refresh and "scroll to top on tab change" one thing to
22
+ * implement rather than one per screen. The content region reserves room for
23
+ * the bar it is drawn under, so the last row of a list is never hidden behind
24
+ * it — the failure this component exists to prevent.
25
+ */
26
+ export function AppShell({ children, className, header, tabs }: AppShellProps) {
27
+ return (
28
+ <div className={cn('nim-app-shell', className)}>
29
+ {header ? <header className="nim-app-shell__header">{header}</header> : null}
30
+ <main className="nim-app-shell__content" data-has-tabs={tabs ? 'true' : undefined}>
31
+ {children}
32
+ </main>
33
+ {tabs ? <TabBar {...tabs} /> : null}
34
+ </div>
35
+ )
36
+ }
@@ -0,0 +1,71 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Button } from '@/components/button'
3
+ import { cn } from '@/lib/cn'
4
+
5
+ export interface AuthScreenProps {
6
+ /** The form itself: a PhoneField, an OtpInput, a PasswordField. */
7
+ children: ReactNode
8
+ className?: string
9
+ /** Brand mark above the heading. */
10
+ brand?: ReactNode
11
+ /** Small link back to the previous step — the OTP step's way home. */
12
+ back?: { label: string; onClick: () => void }
13
+ /** Terms line, support link, resend timer. Sits under the CTA. */
14
+ footer?: ReactNode
15
+ subtitle?: ReactNode
16
+ title: ReactNode
17
+ /** The single primary action. A sign-in screen has exactly one. */
18
+ action?: { disabled?: boolean; label: string; loading?: boolean; onClick: () => void }
19
+ }
20
+
21
+ /**
22
+ * The frame every step of a sign-in flow shares: brand, one heading, one
23
+ * explanation, the fields, and a single action pinned to the bottom.
24
+ *
25
+ * Keeping it one component is what makes the phone step, the code step and the
26
+ * password step read as one screen changing rather than three screens — and it
27
+ * puts the CTA in the same place each time, which is the part a thumb learns.
28
+ */
29
+ export function AuthScreen({
30
+ action,
31
+ back,
32
+ brand,
33
+ children,
34
+ className,
35
+ footer,
36
+ subtitle,
37
+ title,
38
+ }: AuthScreenProps) {
39
+ return (
40
+ <section className={cn('nim-auth', className)}>
41
+ {brand ? <div className="nim-auth__brand">{brand}</div> : null}
42
+
43
+ <div className="nim-auth__body">
44
+ {back ? (
45
+ <Button className="nim-auth__back" iconStart="chevron-back" onClick={back.onClick} size="sm" variant="ghost">
46
+ {back.label}
47
+ </Button>
48
+ ) : null}
49
+ <h1 className="nim-auth__title">{title}</h1>
50
+ {subtitle ? <p className="nim-auth__subtitle">{subtitle}</p> : null}
51
+ <div className="nim-auth__fields">{children}</div>
52
+ </div>
53
+
54
+ <div className="nim-auth__foot">
55
+ {action ? (
56
+ <Button
57
+ disabled={action.disabled}
58
+ fullWidth
59
+ loading={action.loading}
60
+ onClick={action.onClick}
61
+ size="lg"
62
+ variant="accent"
63
+ >
64
+ {action.label}
65
+ </Button>
66
+ ) : null}
67
+ {footer ? <div className="nim-auth__footer">{footer}</div> : null}
68
+ </div>
69
+ </section>
70
+ )
71
+ }
@@ -0,0 +1,32 @@
1
+ import type { HTMLAttributes } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ export type AvatarSize = 'lg' | 'md' | 'sm'
5
+
6
+ export interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {
7
+ /** Falls back to initials derived from `name` when no image is supplied. */
8
+ name: string
9
+ shape?: 'round' | 'square'
10
+ size?: AvatarSize
11
+ src?: string
12
+ }
13
+
14
+ const initials = (name: string) =>
15
+ name
16
+ .split(/\s+/)
17
+ .filter(Boolean)
18
+ .slice(0, 2)
19
+ .map((part) => part[0]?.toUpperCase() ?? '')
20
+ .join('')
21
+
22
+ export function Avatar({ className, name, shape = 'round', size = 'md', src, ...props }: AvatarProps) {
23
+ return (
24
+ <span
25
+ className={cn('nim-avatar', size !== 'md' && `nim-avatar--${size}`, shape === 'square' && 'nim-avatar--square', className)}
26
+ {...props}
27
+ >
28
+ {src ? <img alt="" src={src} /> : <span aria-hidden="true">{initials(name)}</span>}
29
+ <span className="nim-visually-hidden">{name}</span>
30
+ </span>
31
+ )
32
+ }
@@ -0,0 +1,44 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ export type BadgeSize = 'md' | 'sm'
5
+ export type BadgeTone = 'outline' | 'soft' | 'solid'
6
+ export type BadgeVariant = 'accent' | 'danger' | 'info' | 'neutral' | 'success' | 'warning'
7
+
8
+ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
9
+ children: ReactNode
10
+ /** Shows a status dot before the label — for live/among-states meanings. */
11
+ dot?: boolean
12
+ pill?: boolean
13
+ size?: BadgeSize
14
+ tone?: BadgeTone
15
+ variant?: BadgeVariant
16
+ }
17
+
18
+ export function Badge({
19
+ children,
20
+ className,
21
+ dot = false,
22
+ pill = false,
23
+ size = 'md',
24
+ tone = 'soft',
25
+ variant = 'neutral',
26
+ ...props
27
+ }: BadgeProps) {
28
+ return (
29
+ <span
30
+ className={cn(
31
+ 'nim-badge',
32
+ `nim-badge--${variant}`,
33
+ `nim-badge--${tone}`,
34
+ size === 'sm' && 'nim-badge--sm',
35
+ pill && 'nim-badge--pill',
36
+ className,
37
+ )}
38
+ {...props}
39
+ >
40
+ {dot ? <span aria-hidden="true" className="nim-badge__dot" /> : null}
41
+ {children}
42
+ </span>
43
+ )
44
+ }
@@ -0,0 +1,47 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { Icon, type IconName } from '@/components/icon'
3
+ import { cn } from '@/lib/cn'
4
+
5
+ export type BannerTone = 'accent' | 'danger' | 'info' | 'neutral' | 'success' | 'warning'
6
+
7
+ export interface BannerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
8
+ action?: ReactNode
9
+ children: ReactNode
10
+ icon?: IconName
11
+ title?: ReactNode
12
+ tone?: BannerTone
13
+ }
14
+
15
+ const DEFAULT_ICON: Record<BannerTone, IconName> = {
16
+ accent: 'sparkle',
17
+ danger: 'danger',
18
+ info: 'info',
19
+ neutral: 'info',
20
+ success: 'check-circle',
21
+ warning: 'alert',
22
+ }
23
+
24
+ export function Banner({
25
+ action,
26
+ children,
27
+ className,
28
+ icon,
29
+ title,
30
+ tone = 'neutral',
31
+ ...props
32
+ }: BannerProps) {
33
+ return (
34
+ <div
35
+ className={cn('nim-banner', `nim-banner--${tone}`, className)}
36
+ role={tone === 'danger' ? 'alert' : 'status'}
37
+ {...props}
38
+ >
39
+ <Icon className="nim-banner__icon" name={icon ?? DEFAULT_ICON[tone]} size="sm" />
40
+ <div className="nim-banner__content">
41
+ {title ? <p className="nim-banner__title">{title}</p> : null}
42
+ <div>{children}</div>
43
+ </div>
44
+ {action ? <div>{action}</div> : null}
45
+ </div>
46
+ )
47
+ }