@lovett/ui 0.0.1

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 (102) hide show
  1. package/README.md +28 -0
  2. package/package.json +53 -0
  3. package/src/accordion.tsx +153 -0
  4. package/src/atoms.tsx +197 -0
  5. package/src/badge.tsx +91 -0
  6. package/src/brand-logo-tile.tsx +200 -0
  7. package/src/bulk-action-bar.tsx +104 -0
  8. package/src/button-group.tsx +46 -0
  9. package/src/button.tsx +93 -0
  10. package/src/calculator-shell-v2.tsx +380 -0
  11. package/src/calculator-shell.tsx +310 -0
  12. package/src/card.tsx +369 -0
  13. package/src/checkbox.tsx +114 -0
  14. package/src/chip-nav.tsx +347 -0
  15. package/src/choropleth.tsx +62 -0
  16. package/src/code-block.tsx +235 -0
  17. package/src/completion-ring.tsx +119 -0
  18. package/src/copy-field.tsx +169 -0
  19. package/src/data-grid/column-options-menu.tsx +127 -0
  20. package/src/data-grid/data-grid.tsx +391 -0
  21. package/src/data-grid/drawer-panel.tsx +146 -0
  22. package/src/data-grid/index.ts +29 -0
  23. package/src/data-grid/sortable-parts.tsx +215 -0
  24. package/src/data-grid/table-body.tsx +238 -0
  25. package/src/data-grid/table-elements.tsx +135 -0
  26. package/src/data-grid/table-header.tsx +95 -0
  27. package/src/data-grid/table-overlays.tsx +78 -0
  28. package/src/data-grid/table-parts.tsx +15 -0
  29. package/src/data-grid/table-summary-footer.tsx +101 -0
  30. package/src/data-grid/table-view.tsx +176 -0
  31. package/src/data-grid/types.ts +141 -0
  32. package/src/data-grid/use-grid-columns.ts +311 -0
  33. package/src/data-grid/use-grid-editing.ts +154 -0
  34. package/src/device-frame.tsx +274 -0
  35. package/src/dropdown-button.tsx +221 -0
  36. package/src/dropdown-menu.tsx +450 -0
  37. package/src/empty-state.tsx +85 -0
  38. package/src/file-upload/file-drop-zone.tsx +156 -0
  39. package/src/file-upload/file-preview-grid.tsx +39 -0
  40. package/src/file-upload/file-preview-item.tsx +175 -0
  41. package/src/file-upload/file-thumbnail.tsx +98 -0
  42. package/src/file-upload/file-upload-button.tsx +95 -0
  43. package/src/file-upload/index.ts +13 -0
  44. package/src/file-upload/use-file-upload.ts +371 -0
  45. package/src/filter-bar.tsx +125 -0
  46. package/src/filter-dropdown.tsx +289 -0
  47. package/src/floating-drawer.tsx +420 -0
  48. package/src/folder-card.tsx +188 -0
  49. package/src/folder-tree-picker.tsx +511 -0
  50. package/src/format.ts +117 -0
  51. package/src/frame-stack.tsx +73 -0
  52. package/src/hero-form-card.tsx +471 -0
  53. package/src/index.ts +372 -0
  54. package/src/input.tsx +130 -0
  55. package/src/kbd.tsx +41 -0
  56. package/src/lib/layout.ts +22 -0
  57. package/src/lib/utils.ts +6 -0
  58. package/src/markdown.tsx +214 -0
  59. package/src/menu-button.tsx +142 -0
  60. package/src/meta-previews/CreativeMedia.tsx +119 -0
  61. package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
  62. package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
  63. package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
  64. package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
  65. package/src/meta-previews/MetaFeedPreview.tsx +294 -0
  66. package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
  67. package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
  68. package/src/meta-previews/MetaReelPreview.tsx +750 -0
  69. package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
  70. package/src/meta-previews/MetaSearchPreview.tsx +113 -0
  71. package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
  72. package/src/meta-previews/ProfileAvatar.tsx +79 -0
  73. package/src/meta-previews/defaults.ts +27 -0
  74. package/src/meta-previews/index.ts +37 -0
  75. package/src/meta-previews/types.ts +219 -0
  76. package/src/metric-card.tsx +117 -0
  77. package/src/modal.tsx +131 -0
  78. package/src/option-tile.tsx +252 -0
  79. package/src/page-hero.tsx +78 -0
  80. package/src/page-shell.tsx +440 -0
  81. package/src/pagination.tsx +86 -0
  82. package/src/pill-button.tsx +103 -0
  83. package/src/profile-section.tsx +389 -0
  84. package/src/progress-bar.tsx +84 -0
  85. package/src/radio.tsx +172 -0
  86. package/src/range-slider.tsx +161 -0
  87. package/src/resizable.tsx +282 -0
  88. package/src/search-bar.tsx +81 -0
  89. package/src/segmented-pill.tsx +143 -0
  90. package/src/shell.tsx +464 -0
  91. package/src/slider.tsx +107 -0
  92. package/src/sortable-table.tsx +198 -0
  93. package/src/sortable.tsx +137 -0
  94. package/src/stat-row.tsx +132 -0
  95. package/src/stats-grid.tsx +38 -0
  96. package/src/step-loader.tsx +141 -0
  97. package/src/styles.css +562 -0
  98. package/src/tabs.tsx +143 -0
  99. package/src/tag-chip-input.tsx +394 -0
  100. package/src/textarea.tsx +39 -0
  101. package/src/toast.tsx +59 -0
  102. package/src/tokens.css +579 -0
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @lovett/ui
2
+
3
+ Design system for the Lovett portfolio. Tokens, primitives, layouts, and patterns consumed by the workspace, the launcher, and (eventually) the utility and partners apps.
4
+
5
+ ## Phase 0 status
6
+
7
+ This package is **a skeleton**. It exposes:
8
+ - `src/tokens.css` — a minimal placeholder token sheet so the workspace and launcher apps can render against it
9
+ - `src/index.ts` — an empty barrel
10
+
11
+ Phase 1 populates this package by extracting the stabilized design system from `brand-ai-extension/webapp/`. See `docs/phases/MASTER_PLAN.md` (Phase 1) and `docs/adr/ADR-001-workspace-architecture.md`.
12
+
13
+ ## Consumption
14
+
15
+ Apps in the monorepo consume via workspace path:
16
+
17
+ ```ts
18
+ import '@lovett/ui/tokens.css'
19
+ // (primitives are added in Phase 1)
20
+ ```
21
+
22
+ ## Token discipline
23
+
24
+ `packages/ui/tokens.css` is the **single source of truth** for visual design. Apps reference tokens via `rgb(var(--token-name))` or `var(--space-N)`; raw color literals and magic numbers are ESLint errors outside this package. See **CLAUDE.md §4** and **ADR-006**.
25
+
26
+ ## Publishing
27
+
28
+ Today: `private: true`, consumed via workspace path. Publishes to GitHub Packages as `@lovett/ui` once three real external consumers exist (workspace + utility app + partners app). See PRD §4.8.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@lovett/ui",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "type": "module",
9
+ "description": "Design system primitives, tokens, layouts, and patterns for the Lovett portfolio.",
10
+ "main": "./src/index.ts",
11
+ "types": "./src/index.ts",
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./tokens.css": "./src/tokens.css",
15
+ "./styles.css": "./src/styles.css"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "lint": "eslint .",
23
+ "typecheck": "tsc --noEmit -p tsconfig.json",
24
+ "build": "echo 'no-op (source-only consumption in v0)'"
25
+ },
26
+ "peerDependencies": {
27
+ "lucide-react": "^0.469.0",
28
+ "react": "^19.0.0",
29
+ "react-dom": "^19.0.0",
30
+ "react-router-dom": "^7.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^19.0.0",
34
+ "@types/react-dom": "^19.0.0",
35
+ "@types/react-syntax-highlighter": "^15.5.13",
36
+ "eslint": "^9.17.0",
37
+ "lucide-react": "^0.469.0",
38
+ "react-router-dom": "^7.1.0",
39
+ "typescript": "^5.7.2"
40
+ },
41
+ "dependencies": {
42
+ "@dnd-kit/core": "^6.3.1",
43
+ "@dnd-kit/sortable": "^10.0.0",
44
+ "@dnd-kit/utilities": "^3.2.2",
45
+ "clsx": "^2.1.1",
46
+ "mammoth": "^1.12.0",
47
+ "react-markdown": "^10.1.0",
48
+ "react-syntax-highlighter": "^16.1.1",
49
+ "remark-gfm": "^4.0.1",
50
+ "sonner": "^2.0.7",
51
+ "tailwind-merge": "^3.6.0"
52
+ }
53
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Accordion — controlled collapsible-section group.
3
+ *
4
+ * A vertical stack of card sections; each has a rich header (status
5
+ * indicator + title + optional badge + summary + chevron) and a body
6
+ * revealed when open. The consumer owns open state via `openIds` +
7
+ * `onToggle`, so single-open, multi-open, and "expand all" are all
8
+ * just different caller logic.
9
+ *
10
+ * <Accordion openIds={openIds} onToggle={toggle}>
11
+ * <Accordion.Section id="objective" title="Campaign objective"
12
+ * summary="Lead generation" complete>
13
+ * …body…
14
+ * </Accordion.Section>
15
+ * </Accordion>
16
+ *
17
+ * Workspace ADR: ADR-033.
18
+ */
19
+
20
+ import { createContext, useContext, type ReactNode } from 'react'
21
+ import { Check, ChevronDown } from 'lucide-react'
22
+ import { cn } from './lib/utils'
23
+
24
+ interface AccordionContextValue {
25
+ openIds: ReadonlySet<string>
26
+ onToggle: (id: string) => void
27
+ }
28
+
29
+ const AccordionCtx = createContext<AccordionContextValue | null>(null)
30
+
31
+ export interface AccordionProps {
32
+ /** Ids of the currently-open sections. */
33
+ openIds: string[]
34
+ /** Fired with a section id when its header is clicked. */
35
+ onToggle: (id: string) => void
36
+ children: ReactNode
37
+ className?: string
38
+ }
39
+
40
+ function Accordion({ openIds, onToggle, children, className }: AccordionProps) {
41
+ return (
42
+ <AccordionCtx.Provider value={{ openIds: new Set(openIds), onToggle }}>
43
+ <div className={cn('flex flex-col gap-2.5', className)}>{children}</div>
44
+ </AccordionCtx.Provider>
45
+ )
46
+ }
47
+
48
+ export interface AccordionSectionProps {
49
+ /** Stable id — matched against the Accordion's `openIds`. */
50
+ id: string
51
+ title: string
52
+ /** Muted one-line summary under the title. */
53
+ summary?: ReactNode
54
+ /** Optional node right of the title — e.g. a "New" <Badge>. */
55
+ badge?: ReactNode
56
+ /** When true the leading indicator is an accent filled check;
57
+ * otherwise an empty ring. */
58
+ complete?: boolean
59
+ children: ReactNode
60
+ }
61
+
62
+ function AccordionSection({
63
+ id,
64
+ title,
65
+ summary,
66
+ badge,
67
+ complete,
68
+ children,
69
+ }: AccordionSectionProps) {
70
+ const ctx = useContext(AccordionCtx)
71
+ if (!ctx) {
72
+ throw new Error('Accordion.Section must be rendered inside <Accordion>.')
73
+ }
74
+ const open = ctx.openIds.has(id)
75
+
76
+ return (
77
+ <section
78
+ data-slot="accordion-section"
79
+ data-state={open ? 'open' : 'closed'}
80
+ style={{
81
+ background: 'rgb(var(--bg-card))',
82
+ border: '1px solid rgb(var(--border-card))',
83
+ borderRadius: 'var(--radius-lg)',
84
+ }}
85
+ >
86
+ <button
87
+ type="button"
88
+ aria-expanded={open}
89
+ onClick={() => ctx.onToggle(id)}
90
+ className="flex w-full items-center gap-3 px-4 py-3.5 text-left"
91
+ >
92
+ <span
93
+ className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full"
94
+ style={
95
+ complete
96
+ ? {
97
+ background: 'rgb(var(--accent))',
98
+ color: 'rgb(var(--primary-foreground))',
99
+ }
100
+ : { border: '1.5px solid rgb(var(--border-strong))' }
101
+ }
102
+ aria-hidden="true"
103
+ >
104
+ {complete && <Check className="h-3.5 w-3.5" strokeWidth={3} />}
105
+ </span>
106
+
107
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5">
108
+ <span className="flex items-center gap-2">
109
+ <span
110
+ className="text-[14px] font-semibold tracking-[-0.01em]"
111
+ style={{ color: 'rgb(var(--foreground))' }}
112
+ >
113
+ {title}
114
+ </span>
115
+ {badge}
116
+ </span>
117
+ {summary && (
118
+ <span
119
+ className="text-[12.5px] leading-snug"
120
+ style={{ color: 'rgb(var(--text-tertiary))' }}
121
+ >
122
+ {summary}
123
+ </span>
124
+ )}
125
+ </span>
126
+
127
+ <ChevronDown
128
+ className="h-4 w-4 shrink-0 transition-transform"
129
+ style={{
130
+ color: 'rgb(var(--text-tertiary))',
131
+ transform: open ? 'rotate(180deg)' : 'none',
132
+ }}
133
+ aria-hidden="true"
134
+ />
135
+ </button>
136
+
137
+ {open && (
138
+ <div
139
+ className="px-4 pb-4 pt-3.5"
140
+ style={{ borderTop: '1px solid rgb(var(--border))' }}
141
+ >
142
+ {children}
143
+ </div>
144
+ )}
145
+ </section>
146
+ )
147
+ }
148
+
149
+ const AccordionCompound = Object.assign(Accordion, {
150
+ Section: AccordionSection,
151
+ })
152
+
153
+ export { AccordionCompound as Accordion }
package/src/atoms.tsx ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Brand-profile atoms — promoted from brand-ai-extension
3
+ * `webapp/src/components/brand-profile-v2/atoms.tsx` per ADR-010 Decision 3.
4
+ *
5
+ * Seven presentational primitives the Phase 2b brand-profile lens (and
6
+ * later sibling lenses — Discovery, Creative Brief) reach for:
7
+ *
8
+ * IdentityLabel, IdentityValue, EmptyPlaceholder, ListItem, SectionLabel,
9
+ * TextInput, TextareaInput
10
+ *
11
+ * The source's local `Card` + `CardGrid` are intentionally NOT promoted
12
+ * (ADR-010 Decision 4) — the canonical `Card` from `@lovett/ui` replaces
13
+ * both, and the source's `ListItems` wrapper is `<div class="flex flex-col
14
+ * gap-1.5">` boilerplate that callers can inline.
15
+ *
16
+ * `TextInput` + `TextareaInput` are thin (string)-callback wrappers around
17
+ * the canonical `<Input>` + `<Textarea>` so source section files don't
18
+ * need callsite churn during the port.
19
+ */
20
+
21
+ import type { ReactNode } from 'react'
22
+ import { Input } from './input'
23
+ import { Textarea } from './textarea'
24
+
25
+ // =======================================================================
26
+ // LABELS
27
+ // =======================================================================
28
+
29
+ export function SectionLabel({ children }: { children: ReactNode }) {
30
+ return (
31
+ <div className="text-[11px] font-semibold uppercase tracking-[0.5px] text-[rgb(var(--text-muted))]">
32
+ {children}
33
+ </div>
34
+ )
35
+ }
36
+
37
+ export function IdentityLabel({ children }: { children: ReactNode }) {
38
+ return (
39
+ <div className="text-[11px] font-semibold uppercase tracking-[0.4px] text-[rgb(var(--text-muted))] mb-1.5">
40
+ {children}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ export function IdentityValue({
46
+ children,
47
+ className = '',
48
+ }: {
49
+ children: ReactNode
50
+ className?: string
51
+ }) {
52
+ return (
53
+ <div
54
+ className={`text-[14px] text-[rgb(var(--foreground))] ${className}`}
55
+ style={{ lineHeight: 1.6 }}
56
+ >
57
+ {children}
58
+ </div>
59
+ )
60
+ }
61
+
62
+ // =======================================================================
63
+ // INPUTS
64
+ // Thin (string)-callback wrappers around the canonical <Input> / <Textarea>.
65
+ // Section files across brand-profile-v2 pass `value + onChange(string)` so
66
+ // keeping that API means zero callsite churn during the Phase 2b port.
67
+ // =======================================================================
68
+
69
+ export function TextInput({
70
+ value,
71
+ onChange,
72
+ placeholder,
73
+ autoFocus,
74
+ autoComplete,
75
+ name,
76
+ }: {
77
+ value: string
78
+ onChange: (v: string) => void
79
+ placeholder?: string | undefined
80
+ autoFocus?: boolean | undefined
81
+ /** Forwarded so the browser's autofill / password manager can identify the field. */
82
+ autoComplete?: string | undefined
83
+ /** Forwarded so the field participates in form submission / autofill heuristics. */
84
+ name?: string | undefined
85
+ }) {
86
+ return (
87
+ <Input
88
+ type="text"
89
+ value={value}
90
+ onChange={(e) => onChange(e.target.value)}
91
+ placeholder={placeholder}
92
+ autoFocus={autoFocus}
93
+ autoComplete={autoComplete}
94
+ name={name}
95
+ />
96
+ )
97
+ }
98
+
99
+ export function TextareaInput({
100
+ value,
101
+ onChange,
102
+ placeholder,
103
+ autoFocus,
104
+ autoComplete,
105
+ name,
106
+ rows = 4,
107
+ }: {
108
+ value: string
109
+ onChange: (v: string) => void
110
+ placeholder?: string | undefined
111
+ autoFocus?: boolean | undefined
112
+ autoComplete?: string | undefined
113
+ name?: string | undefined
114
+ rows?: number | undefined
115
+ }) {
116
+ return (
117
+ <Textarea
118
+ value={value}
119
+ onChange={(e) => onChange(e.target.value)}
120
+ placeholder={placeholder}
121
+ autoFocus={autoFocus}
122
+ autoComplete={autoComplete}
123
+ name={name}
124
+ rows={rows}
125
+ />
126
+ )
127
+ }
128
+
129
+ // =======================================================================
130
+ // EMPTY PLACEHOLDER (always-visible placeholder card for missing content)
131
+ // =======================================================================
132
+
133
+ export function EmptyPlaceholder({
134
+ label,
135
+ description,
136
+ onAdd,
137
+ }: {
138
+ label: string
139
+ description?: string | undefined
140
+ onAdd?: (() => void) | undefined
141
+ }) {
142
+ const clickable = !!onAdd
143
+ return (
144
+ <div
145
+ onClick={onAdd}
146
+ className={`flex flex-col items-center justify-center text-center py-8 px-6 transition-colors ${
147
+ clickable ? 'cursor-pointer' : ''
148
+ }`}
149
+ style={{
150
+ background: 'rgb(var(--bg-card))',
151
+ border: '1px dashed rgb(var(--border-strong))',
152
+ borderRadius: 'var(--radius-lg)',
153
+ }}
154
+ onMouseEnter={(e) => {
155
+ if (!clickable) return
156
+ e.currentTarget.style.background = 'rgb(var(--accent-subtle))'
157
+ e.currentTarget.style.borderColor = 'rgb(var(--accent))'
158
+ }}
159
+ onMouseLeave={(e) => {
160
+ if (!clickable) return
161
+ e.currentTarget.style.background = 'rgb(var(--bg-card))'
162
+ e.currentTarget.style.borderColor = 'rgb(var(--border-strong))'
163
+ }}
164
+ >
165
+ <div className="text-[13px] font-medium text-[rgb(var(--text-secondary))] mb-1">
166
+ {clickable ? `+ Add ${label}` : `No ${label} yet`}
167
+ </div>
168
+ {description && (
169
+ <div className="text-[11px] text-[rgb(var(--text-muted))] max-w-xs">{description}</div>
170
+ )}
171
+ </div>
172
+ )
173
+ }
174
+
175
+ // =======================================================================
176
+ // LIST ITEMS (with accent bullet)
177
+ // =======================================================================
178
+
179
+ export function ListItem({ children }: { children: ReactNode }) {
180
+ return (
181
+ <div
182
+ className="flex items-start gap-2 text-[13px] text-[rgb(var(--text-secondary))]"
183
+ style={{ lineHeight: 1.5 }}
184
+ >
185
+ <div
186
+ className="rounded-full shrink-0"
187
+ style={{
188
+ width: 'var(--bullet-size)',
189
+ height: 'var(--bullet-size)',
190
+ background: 'rgb(var(--accent))',
191
+ marginTop: 'var(--bullet-offset-y)',
192
+ }}
193
+ />
194
+ <div>{children}</div>
195
+ </div>
196
+ )
197
+ }
package/src/badge.tsx ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Badge — semantic pill chip with optional leading icon.
3
+ *
4
+ * Promoted in ADR-021 (Phase 3b Discovery Contacts, first committed
5
+ * consumer — decision-maker level + salary range). Cross-batch
6
+ * consumers: Phase 8 (keyword competition + brand-type), Phase 12
7
+ * (validation severity).
8
+ *
9
+ * Distinct from EmptyPlaceholder + EmptyState + ChipNav.ChipNavItem —
10
+ * this is the pure-pill labeled-chip primitive for inline use within
11
+ * other components.
12
+ *
13
+ * Token discipline: all colors via public semantic tokens. Each tone
14
+ * pairs a background-tint + foreground-color from the matching token
15
+ * pair (--success / --success-bg, etc.).
16
+ */
17
+
18
+ import type { ReactNode } from 'react'
19
+
20
+ export type BadgeTone =
21
+ | 'neutral'
22
+ | 'accent'
23
+ | 'success'
24
+ | 'warning'
25
+ | 'destructive'
26
+ | 'info'
27
+
28
+ const TONE_STYLE: Record<BadgeTone, { bg: string; color: string }> = {
29
+ neutral: {
30
+ bg: 'rgb(var(--surface-overlay))',
31
+ color: 'rgb(var(--text-secondary))',
32
+ },
33
+ accent: {
34
+ bg: 'rgb(var(--accent-subtle))',
35
+ color: 'rgb(var(--accent))',
36
+ },
37
+ success: {
38
+ bg: 'rgb(var(--success-bg))',
39
+ color: 'rgb(var(--success))',
40
+ },
41
+ warning: {
42
+ bg: 'rgb(var(--warning-bg))',
43
+ color: 'rgb(var(--warning))',
44
+ },
45
+ destructive: {
46
+ bg: 'rgb(var(--destructive) / 0.12)',
47
+ color: 'rgb(var(--destructive))',
48
+ },
49
+ info: {
50
+ bg: 'rgb(var(--info-bg))',
51
+ color: 'rgb(var(--info))',
52
+ },
53
+ }
54
+
55
+ export interface BadgeProps {
56
+ tone: BadgeTone
57
+ children: ReactNode
58
+ /** Optional leading icon — sized 12-14px expected. */
59
+ icon?: ReactNode
60
+ /** Size variant. `sm` = compact (10px text), `md` = default (11px). */
61
+ size?: 'sm' | 'md'
62
+ /** Optional className override on the outer span. */
63
+ className?: string
64
+ }
65
+
66
+ export function Badge({
67
+ tone,
68
+ children,
69
+ icon,
70
+ size = 'md',
71
+ className,
72
+ }: BadgeProps) {
73
+ const { bg, color } = TONE_STYLE[tone]
74
+ const sizeClass = size === 'sm' ? 'text-[10px] px-1.5 py-0.5' : 'text-[11px] px-2 py-0.5'
75
+ return (
76
+ <span
77
+ className={
78
+ className ??
79
+ `inline-flex items-center gap-1 font-medium ${sizeClass}`
80
+ }
81
+ style={{
82
+ background: bg,
83
+ color,
84
+ borderRadius: 'var(--radius-full)',
85
+ }}
86
+ >
87
+ {icon && <span className="flex-shrink-0">{icon}</span>}
88
+ {children}
89
+ </span>
90
+ )
91
+ }