@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.
- package/README.md +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- 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
|
+
}
|