@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
@@ -0,0 +1,200 @@
1
+ /**
2
+ * BrandLogoTile — the rounded-square logo container used wherever the
3
+ * app shows a brand's identity at a glance: the brand profile rail
4
+ * (78px), the sidebar switcher button (36px), dropdown rows (28px),
5
+ * the dashboard hero tile, etc.
6
+ *
7
+ * <BrandLogoTile size="lg" url={brand.logoUrl} name={brand.name} />
8
+ * <BrandLogoTile size={56} url={...} name={...} />
9
+ * <BrandLogoTile size="md" name="Acme" /> // initial-only
10
+ *
11
+ * Sizes are tokens (sm/md/lg) but custom numbers work too — radius
12
+ * scales proportionally so a 64px tile looks right without manual
13
+ * tuning. Pass `accentGlow` to add the subtle radial halo used on the
14
+ * brand profile rail tray.
15
+ */
16
+
17
+ import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'
18
+ import { cn } from './lib/utils'
19
+
20
+ export type BrandLogoTileSize = 'xs' | 'sm' | 'md' | 'lg' | number
21
+
22
+ // Named sizes map to the most common diameters in the app. Keep
23
+ // these in sync with patterns.md when adding entries.
24
+ const SIZE_PX: Record<Exclude<BrandLogoTileSize, number>, number> = {
25
+ xs: 28, // dropdown rows
26
+ sm: 36, // collapsed sidebar
27
+ md: 56, // dashboard hero
28
+ lg: 78, // brand profile rail
29
+ }
30
+
31
+ function resolveSize(size: BrandLogoTileSize): number {
32
+ return typeof size === 'number' ? size : SIZE_PX[size]
33
+ }
34
+
35
+ /**
36
+ * Radius scales with diameter so every size looks like the same shape
37
+ * family. Coefficient 0.23 chosen to match the rail's 78px → 18px ratio.
38
+ * Floored to whole px since sub-pixel radii render fuzzy on some GPUs.
39
+ */
40
+ function radiusFor(size: number): number {
41
+ return Math.max(6, Math.floor(size * 0.23))
42
+ }
43
+
44
+ /** Inner padding for image children — bigger for full logos so the
45
+ * glyph doesn't kiss the edges, zero for tiny favicons that need to
46
+ * fill the tile. */
47
+ function paddingFor(size: number, isFavicon: boolean): number {
48
+ if (isFavicon) return 0
49
+ if (size <= 36) return 4
50
+ if (size <= 56) return 8
51
+ return 10
52
+ }
53
+
54
+ export interface BrandLogoTileProps {
55
+ /** Size token or a raw pixel number. Defaults to `md` (56px). */
56
+ size?: BrandLogoTileSize
57
+ /** Image URL — logo, favicon, anything `<img>` can render. */
58
+ url?: string | null
59
+ /**
60
+ * Brand name. Used as `alt` text and as the source for the
61
+ * single-character fallback when no `url` is provided (or the image
62
+ * fails to load).
63
+ */
64
+ name?: string
65
+ /**
66
+ * Treat the image as a favicon — render it smaller, centred, without
67
+ * stretching to fill the tile. Auto-detected when the URL starts
68
+ * with the Google s2 favicon endpoint; pass `true`/`false` to override.
69
+ */
70
+ isFavicon?: boolean
71
+ /**
72
+ * Adds a subtle accent-coloured radial glow above the tile. Used on
73
+ * the brand profile rail to give the logo a sense of presence
74
+ * against the slate frame.
75
+ */
76
+ accentGlow?: boolean
77
+ /** Override the fallback character (defaults to `name[0]`). */
78
+ fallbackChar?: ReactNode
79
+ /**
80
+ * Override the tile background. Use for contexts that have their own
81
+ * visual identity (e.g. the sidebar switcher's blue gradient). When
82
+ * omitted, the tile picks up the neutral card→overlay gradient that
83
+ * blends with surrounding chrome.
84
+ */
85
+ background?: string
86
+ /**
87
+ * Override the fallback character / image-area text colour. Pair
88
+ * with `background` for branded variants (white text on coloured
89
+ * gradient). Defaults to the accent token.
90
+ */
91
+ color?: string
92
+ /** Drop the 1px border (used when the tile sits on a strongly tinted bg). */
93
+ borderless?: boolean
94
+ className?: string
95
+ style?: CSSProperties
96
+ }
97
+
98
+ export default function BrandLogoTile({
99
+ size = 'md',
100
+ url,
101
+ name,
102
+ isFavicon,
103
+ accentGlow,
104
+ fallbackChar,
105
+ background,
106
+ color,
107
+ borderless,
108
+ className,
109
+ style,
110
+ }: BrandLogoTileProps) {
111
+ const px = resolveSize(size)
112
+ const radius = radiusFor(px)
113
+ const fav = isFavicon ?? url?.includes('s2/favicons') ?? false
114
+ const pad = paddingFor(px, fav)
115
+ // Initial text scales with the tile so a 78px tile gets a big 34-ish
116
+ // glyph and a 28px tile gets a 12-ish one. Tuned against the rail
117
+ // (78 → 34) — coefficient ≈ 0.44.
118
+ const initialFontSize = Math.max(11, Math.round(px * 0.44))
119
+
120
+ const initial = (
121
+ typeof fallbackChar === 'string'
122
+ ? fallbackChar
123
+ : (name?.charAt(0).toUpperCase() ?? '?')
124
+ ) as ReactNode
125
+
126
+ // Favicon/logo images that fail to load (404, blocked, dead host) fall
127
+ // back to the initial. We track the error in state so the <img> is
128
+ // *replaced* by the letter span — hiding the broken img alone left an
129
+ // empty tile (the letter only renders in the no-url branch). The flag
130
+ // resets when `url` changes so a reused tile re-attempts the new image.
131
+ const [imgError, setImgError] = useState(false)
132
+ useEffect(() => {
133
+ setImgError(false)
134
+ }, [url])
135
+ const showImg = !!url && !imgError
136
+
137
+ return (
138
+ <div
139
+ className={cn(
140
+ 'relative inline-flex items-center justify-center overflow-hidden shrink-0',
141
+ className,
142
+ )}
143
+ style={{
144
+ width: px,
145
+ height: px,
146
+ borderRadius: radius,
147
+ background:
148
+ background ??
149
+ 'linear-gradient(180deg, rgb(var(--bg-card)) 0%, rgb(var(--surface-overlay)) 100%)',
150
+ border: borderless ? 'none' : '1px solid rgb(var(--border))',
151
+ color: color ?? 'rgb(var(--accent))',
152
+ boxShadow: borderless ? undefined : 'var(--shadow-sm)',
153
+ ...style,
154
+ }}
155
+ >
156
+ {accentGlow && (
157
+ <span
158
+ aria-hidden
159
+ className="absolute pointer-events-none"
160
+ style={{
161
+ top: -Math.round(px * 0.5),
162
+ left: '50%',
163
+ transform: 'translateX(-50%)',
164
+ width: Math.round(px * 2.6),
165
+ height: Math.round(px * 1.05),
166
+ background:
167
+ 'radial-gradient(closest-side, rgb(var(--accent) / 0.18), transparent 70%)',
168
+ }}
169
+ />
170
+ )}
171
+ {showImg ? (
172
+ <img
173
+ src={url ?? undefined}
174
+ alt={name ?? ''}
175
+ className="object-contain"
176
+ style={{
177
+ width: fav ? '60%' : '100%',
178
+ height: fav ? '60%' : '100%',
179
+ padding: pad,
180
+ }}
181
+ onError={() => {
182
+ // Fall back to the initial silently — swap the img for the
183
+ // fallback character (see imgError note above).
184
+ setImgError(true)
185
+ }}
186
+ />
187
+ ) : (
188
+ <span
189
+ className="font-extrabold leading-none"
190
+ style={{
191
+ fontSize: initialFontSize,
192
+ letterSpacing: '-0.04em',
193
+ }}
194
+ >
195
+ {fallbackChar ?? initial}
196
+ </span>
197
+ )}
198
+ </div>
199
+ )
200
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * BulkActionBar — floating, bottom-centred selection action bar.
3
+ *
4
+ * Shown when a multi-select surface has a non-empty selection. The
5
+ * primitive owns the fixed bottom-centre shell, the accent count
6
+ * badge, the dividers, and the Clear button; the caller passes its
7
+ * own action buttons as `children`.
8
+ *
9
+ * Promoted to @lovett/ui in ADR-035 (Assets lens redesign) — two
10
+ * committed consumers (the Assets page and the Manage Folders page)
11
+ * and it needs the internal surface tokens (`--bg-card` /
12
+ * `--border-card`).
13
+ */
14
+
15
+ import type { ReactNode } from 'react'
16
+ import { X } from 'lucide-react'
17
+
18
+ export interface BulkActionBarProps {
19
+ /** Selection count shown in the accent badge. */
20
+ count: number
21
+ /** Optional summary string, e.g. "2 folders · 3 assets". When
22
+ * omitted, a plain "<count> selected" label is shown. */
23
+ summary?: string
24
+ /** Caller-owned action buttons (icon buttons or labelled Buttons). */
25
+ children: ReactNode
26
+ onClear: () => void
27
+ }
28
+
29
+ function Divider() {
30
+ return (
31
+ <span
32
+ aria-hidden="true"
33
+ className="h-[22px] w-px shrink-0"
34
+ style={{ background: 'rgb(var(--border))' }}
35
+ />
36
+ )
37
+ }
38
+
39
+ export function BulkActionBar({
40
+ count,
41
+ summary,
42
+ children,
43
+ onClear,
44
+ }: BulkActionBarProps) {
45
+ return (
46
+ <div
47
+ role="region"
48
+ aria-label="Bulk actions"
49
+ className="fixed bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2.5 py-2.5 pl-4 pr-3"
50
+ style={{
51
+ background: 'rgb(var(--bg-card))',
52
+ border: '1px solid rgb(var(--border-card))',
53
+ borderRadius: 'var(--radius-lg)',
54
+ boxShadow: 'var(--shadow-lg)',
55
+ }}
56
+ >
57
+ <span className="inline-flex items-center gap-2">
58
+ <span
59
+ className="inline-flex h-[22px] min-w-[22px] items-center justify-center px-1.5 text-[11.5px] font-bold tabular-nums text-white"
60
+ style={{
61
+ background: 'rgb(var(--accent))',
62
+ borderRadius: 'var(--radius-full)',
63
+ }}
64
+ >
65
+ {count}
66
+ </span>
67
+ <span
68
+ className="text-[13px] font-semibold"
69
+ style={{ color: 'rgb(var(--foreground))' }}
70
+ >
71
+ {summary ?? 'selected'}
72
+ </span>
73
+ </span>
74
+
75
+ <Divider />
76
+
77
+ <div className="flex items-center gap-1.5">{children}</div>
78
+
79
+ <Divider />
80
+
81
+ <button
82
+ type="button"
83
+ onClick={onClear}
84
+ className="inline-flex h-7 items-center gap-1.5 px-2.5 cursor-pointer text-[12px] font-medium transition-colors"
85
+ style={{
86
+ background: 'transparent',
87
+ color: 'rgb(var(--text-tertiary))',
88
+ borderRadius: 'var(--radius-sm)',
89
+ }}
90
+ onMouseEnter={(e) => {
91
+ e.currentTarget.style.background = 'rgb(var(--surface-overlay))'
92
+ e.currentTarget.style.color = 'rgb(var(--foreground))'
93
+ }}
94
+ onMouseLeave={(e) => {
95
+ e.currentTarget.style.background = 'transparent'
96
+ e.currentTarget.style.color = 'rgb(var(--text-tertiary))'
97
+ }}
98
+ >
99
+ <X className="h-3.5 w-3.5" aria-hidden="true" />
100
+ Clear
101
+ </button>
102
+ </div>
103
+ )
104
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * ButtonGroup — typed wrapper over the `.btn-group` CSS class in styles.css.
3
+ *
4
+ * Renders Button children as a joined segmented control (single-row,
5
+ * shared borders, rounded outer corners). Used for view-switchers,
6
+ * mode toggles, and other "pick one of N" affordances where the
7
+ * options should read as a connected group.
8
+ *
9
+ * Promoted from inline CSS-class usage to a typed primitive per
10
+ * ADR-026 D5 (inspired by gray-ui-csm recon §13 DQ-5 — the
11
+ * view-switcher button group).
12
+ *
13
+ * <ButtonGroup aria-label="View mode">
14
+ * <Button variant="secondary" aria-pressed={view === 'grid'}>
15
+ * <Grid /> Grid
16
+ * </Button>
17
+ * <Button variant="secondary" aria-pressed={view === 'list'}>
18
+ * <List /> List
19
+ * </Button>
20
+ * </ButtonGroup>
21
+ *
22
+ * The first/last child get rounded outer corners; inner segments are
23
+ * square. Inner borders deduplicate via `border-right-width: 0` on
24
+ * non-last children.
25
+ */
26
+
27
+ import type { HTMLAttributes, ReactNode } from 'react'
28
+ import { cn } from './lib/utils'
29
+
30
+ export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> {
31
+ /** One or more `<Button>` elements. */
32
+ children: ReactNode
33
+ }
34
+
35
+ export function ButtonGroup({
36
+ children,
37
+ className,
38
+ role = 'group',
39
+ ...rest
40
+ }: ButtonGroupProps) {
41
+ return (
42
+ <div role={role} className={cn('btn-group', className)} {...rest}>
43
+ {children}
44
+ </div>
45
+ )
46
+ }
package/src/button.tsx ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Button — typed primitive over the .btn CSS classes in styles.css.
3
+ *
4
+ * variants: primary | secondary | outline | ghost | destructive |
5
+ * destructive-soft | icon | toolbar
6
+ * sizes: sm (32) | md (40) — default | lg (48)
7
+ * shape: default | circle (use with variant="icon" — ADR-026 D3)
8
+ * states: loading, disabled — both forwarded to the CSS class
9
+ */
10
+ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
11
+ import { cn } from './lib/utils'
12
+
13
+ export type ButtonVariant =
14
+ | 'primary'
15
+ | 'secondary'
16
+ | 'outline'
17
+ | 'ghost'
18
+ | 'destructive'
19
+ | 'destructive-soft'
20
+ | 'icon'
21
+ | 'toolbar'
22
+
23
+ export type ButtonSize = 'sm' | 'md' | 'lg'
24
+
25
+ export type ButtonShape = 'default' | 'circle'
26
+
27
+ const VARIANT_CLASS: Record<ButtonVariant, string> = {
28
+ primary: 'btn-primary',
29
+ secondary: 'btn-secondary',
30
+ outline: 'btn-outline',
31
+ ghost: 'btn-ghost',
32
+ destructive: 'btn-destructive',
33
+ 'destructive-soft': 'btn-destructive-soft',
34
+ icon: 'btn-icon',
35
+ toolbar: 'btn-toolbar',
36
+ }
37
+
38
+ const SIZE_CLASS: Record<ButtonSize, string> = {
39
+ sm: 'size-sm',
40
+ md: '',
41
+ lg: 'size-lg',
42
+ }
43
+
44
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
45
+ variant?: ButtonVariant
46
+ size?: ButtonSize
47
+ /** ADR-026 D3 — `circle` only takes effect when `variant="icon"`. */
48
+ shape?: ButtonShape
49
+ /** Show the loading spinner overlay; auto-disables the button. */
50
+ loading?: boolean
51
+ /** Optional left-side icon. Forces icon-text gap from .btn. */
52
+ leadingIcon?: ReactNode
53
+ /** Optional right-side icon. */
54
+ trailingIcon?: ReactNode
55
+ }
56
+
57
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
58
+ {
59
+ variant = 'primary',
60
+ size = 'md',
61
+ shape = 'default',
62
+ loading,
63
+ leadingIcon,
64
+ trailingIcon,
65
+ disabled,
66
+ className,
67
+ children,
68
+ type = 'button',
69
+ ...rest
70
+ },
71
+ ref,
72
+ ) {
73
+ return (
74
+ <button
75
+ ref={ref}
76
+ type={type}
77
+ disabled={disabled || loading}
78
+ className={cn(
79
+ 'btn',
80
+ VARIANT_CLASS[variant],
81
+ SIZE_CLASS[size],
82
+ shape === 'circle' && variant === 'icon' && 'is-circle',
83
+ loading && 'is-loading',
84
+ className,
85
+ )}
86
+ {...rest}
87
+ >
88
+ {leadingIcon}
89
+ {children}
90
+ {trailingIcon}
91
+ </button>
92
+ )
93
+ })