@lovett/ui 0.0.5 → 0.0.6

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 (56) hide show
  1. package/dist/index.d.ts +279 -115
  2. package/dist/index.js +479 -66
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +74 -0
  5. package/dist/tokens.css +181 -60
  6. package/package.json +21 -4
  7. package/src/__tests__/button.test.tsx +137 -0
  8. package/src/__tests__/card.test.tsx +103 -0
  9. package/src/__tests__/dead-render.test.tsx +117 -0
  10. package/src/__tests__/input.test.tsx +134 -0
  11. package/src/__tests__/modal.test.tsx +154 -0
  12. package/src/__tests__/page-shell.test.tsx +128 -0
  13. package/src/__tests__/setup.ts +43 -0
  14. package/src/__tests__/token-shape.test.ts +193 -0
  15. package/src/card.tsx +1 -1
  16. package/src/collapsible-card.tsx +85 -0
  17. package/src/data-grid/table-body.tsx +8 -1
  18. package/src/dropdown-menu.tsx +1 -1
  19. package/src/floating-status-bar.tsx +1 -1
  20. package/src/folder-tree-picker.tsx +5 -6
  21. package/src/frame-stack.tsx +27 -10
  22. package/src/hero-form-card.tsx +2 -2
  23. package/src/icons/brand.tsx +187 -0
  24. package/src/index.ts +32 -0
  25. package/src/lib/clipboard.ts +14 -0
  26. package/src/lib/color.ts +111 -0
  27. package/src/meta-cell.tsx +52 -0
  28. package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
  29. package/src/meta-previews/MetaFeedPreview.tsx +1 -1
  30. package/src/modal.tsx +77 -6
  31. package/src/pill-button.tsx +23 -5
  32. package/src/profile-section.tsx +40 -9
  33. package/src/sortable-table.tsx +5 -1
  34. package/src/styles.css +74 -0
  35. package/src/tabs.tsx +4 -0
  36. package/src/tag-chip-input.tsx +1 -1
  37. package/src/theme-v2.css +466 -0
  38. package/src/tokens.css +181 -60
  39. package/src/v2/README.md +208 -0
  40. package/src/v2/__demo__/showcase.tsx +1045 -0
  41. package/src/v2/action.tsx +91 -0
  42. package/src/v2/callout.tsx +76 -0
  43. package/src/v2/document-section.tsx +82 -0
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +113 -0
  46. package/src/v2/icons.tsx +165 -0
  47. package/src/v2/index.ts +147 -0
  48. package/src/v2/layout.tsx +293 -0
  49. package/src/v2/progress-track.tsx +89 -0
  50. package/src/v2/stat-tile.tsx +129 -0
  51. package/src/v2/states.tsx +271 -0
  52. package/src/v2/status-pill.tsx +74 -0
  53. package/src/v2/theme.css +1861 -0
  54. package/src/v2/timeline.tsx +81 -0
  55. package/src/v2/tokens.ts +228 -0
  56. package/src/value-chip.tsx +76 -0
@@ -0,0 +1,193 @@
1
+ // @vitest-environment node
2
+ //
3
+ // Pure source scan — no DOM needed, and jsdom rewrites import.meta.url to
4
+ // an http URL that fileURLToPath rejects.
5
+ /**
6
+ * Static guard for the "type-checks, lints, builds, renders nothing"
7
+ * defect class — CSS that is syntactically fine and semantically void.
8
+ *
9
+ * WHY THIS IS A SOURCE SCAN AND NOT A RENDER TEST
10
+ * ------------------------------------------------
11
+ * The first attempt at this asserted rendered inline styles. It could not
12
+ * work: any value containing `var()` is "pending substitution", so jsdom
13
+ * (and browsers, at parse time) store it verbatim without validating it.
14
+ * `rgb(var(--surface-overlay-soft) / 0.5)` and `rgb(var(--muted))` are
15
+ * indistinguishable through the CSSOM — a render test happily passes on
16
+ * the broken form. The invalidity only appears at computed-value time in
17
+ * a real browser, which is precisely why typecheck, lint, build and the
18
+ * whole test suite were all green while three primitives shipped with no
19
+ * error border and every SortableTable shipped unbanded.
20
+ *
21
+ * So this reads the source instead, and derives its rules from
22
+ * tokens.css rather than hardcoding a token list — a new alpha-carrying
23
+ * or shadow-valued token is covered the day it is added.
24
+ *
25
+ * The two rules:
26
+ *
27
+ * 1. A token whose VALUE is a shadow list ("0 0 0 3px rgb(...)") can
28
+ * never appear inside rgb(). `rgb(var(--ring-error))` expands to
29
+ * `rgb(0 0 0 3px rgb(...))`, which is dropped.
30
+ *
31
+ * 2. A token whose VALUE already carries an alpha ("0 0 0 / 0.03")
32
+ * can never take a second one. `rgb(var(--surface-overlay-soft) / 0.5)`
33
+ * expands to `rgb(0 0 0 / 0.03 / 0.5)`, which is dropped.
34
+ */
35
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
36
+ import { join, relative } from 'node:path'
37
+ import { fileURLToPath } from 'node:url'
38
+ import { describe, expect, it } from 'vitest'
39
+
40
+ const SRC = fileURLToPath(new URL('..', import.meta.url))
41
+
42
+ function sourceFiles(dir: string, acc: string[] = []): string[] {
43
+ for (const entry of readdirSync(dir)) {
44
+ if (entry === 'node_modules' || entry === '__tests__') continue
45
+ const full = join(dir, entry)
46
+ if (statSync(full).isDirectory()) sourceFiles(full, acc)
47
+ else if (/\.(tsx?|css)$/.test(entry) && entry !== 'tokens.css') acc.push(full)
48
+ }
49
+ return acc
50
+ }
51
+
52
+ /** Parse tokens.css into { name -> [values across all theme blocks] }. */
53
+ function readTokenValues(): Map<string, string[]> {
54
+ const css = readFileSync(join(SRC, 'tokens.css'), 'utf8')
55
+ const out = new Map<string, string[]>()
56
+ for (const [, name, value] of css.matchAll(/^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gm)) {
57
+ const list = out.get(name!) ?? []
58
+ list.push(value!.trim())
59
+ out.set(name!, list)
60
+ }
61
+ return out
62
+ }
63
+
64
+ const TOKENS = readTokenValues()
65
+
66
+ /** A shadow list — has a length unit, so it is not a colour. */
67
+ const isShadowValued = (values: string[]) =>
68
+ values.some((v) => /\b\d+(px|rem|em)\b/.test(v) || v.startsWith('inset '))
69
+
70
+ /** Already carries an alpha, so it cannot take a second one. */
71
+ const isAlphaCarrying = (values: string[]) =>
72
+ values.some((v) => /^[\d\s.]+\/\s*[\d.]+$/.test(v))
73
+
74
+ /**
75
+ * Blank out comments while preserving line numbers, so a violation still
76
+ * reports the line it is on. Without this the scan flags its own
77
+ * explanatory comments — every doc-block that spells out the broken form
78
+ * in order to warn against it.
79
+ *
80
+ * Line comments are only stripped when the line STARTS with `//`, so a
81
+ * `https://` inside a string is left intact.
82
+ */
83
+ function stripComments(src: string): string {
84
+ return src
85
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
86
+ .split('\n')
87
+ .map((line) => (/^\s*\/\//.test(line) ? '' : line))
88
+ .join('\n')
89
+ }
90
+
91
+ const FILES = sourceFiles(SRC)
92
+ const rel = (f: string) => relative(SRC, f)
93
+ const readCode = (f: string) => stripComments(readFileSync(f, 'utf8')).split('\n')
94
+
95
+ describe('token shape', () => {
96
+ it('parsed a plausible token file', () => {
97
+ // Guard the guard: if the parse silently returned nothing, every
98
+ // assertion below would vacuously pass.
99
+ expect(TOKENS.size).toBeGreaterThan(100)
100
+ expect(isShadowValued(TOKENS.get('--ring-error') ?? [])).toBe(true)
101
+ expect(isAlphaCarrying(TOKENS.get('--surface-overlay-soft') ?? [])).toBe(true)
102
+ expect(isShadowValued(TOKENS.get('--accent') ?? [])).toBe(false)
103
+ expect(isAlphaCarrying(TOKENS.get('--accent') ?? [])).toBe(false)
104
+ })
105
+
106
+ it('finds source files to scan', () => {
107
+ expect(FILES.length).toBeGreaterThan(50)
108
+ })
109
+
110
+ it('never wraps a shadow-valued token in rgb()', () => {
111
+ const violations: string[] = []
112
+ for (const file of FILES) {
113
+ readCode(file).forEach((line, i) => {
114
+ for (const [, token] of line.matchAll(/rgb\(\s*var\((--[a-z0-9-]+)\)/g)) {
115
+ if (isShadowValued(TOKENS.get(token!) ?? [])) {
116
+ violations.push(`${rel(file)}:${i + 1} — rgb(var(${token})) is a box-shadow list, not a colour`)
117
+ }
118
+ }
119
+ })
120
+ }
121
+ expect(violations).toEqual([])
122
+ })
123
+
124
+ it('never nests a second alpha on an alpha-carrying token', () => {
125
+ const violations: string[] = []
126
+ for (const file of FILES) {
127
+ readCode(file).forEach((line, i) => {
128
+ for (const [, token] of line.matchAll(
129
+ /rgb\(\s*var\((--[a-z0-9-]+)\)\s*\/\s*[\d.]/g,
130
+ )) {
131
+ if (isAlphaCarrying(TOKENS.get(token!) ?? [])) {
132
+ violations.push(`${rel(file)}:${i + 1} — var(${token}) already carries an alpha`)
133
+ }
134
+ }
135
+ })
136
+ }
137
+ expect(violations).toEqual([])
138
+ })
139
+
140
+ it('never references a token that tokens.css does not define', () => {
141
+ const violations: string[] = []
142
+ for (const file of FILES) {
143
+ readCode(file).forEach((line, i) => {
144
+ for (const [, token] of line.matchAll(/var\((--[a-z0-9-]+)[,)]/g)) {
145
+ // Tailwind's own custom properties are not ours to define.
146
+ if (token!.startsWith('--tw-')) continue
147
+ if (!TOKENS.has(token!)) {
148
+ violations.push(`${rel(file)}:${i + 1} — var(${token}) is not defined in tokens.css`)
149
+ }
150
+ }
151
+ })
152
+ }
153
+ expect(violations).toEqual([])
154
+ })
155
+ })
156
+
157
+ describe('interpolated Tailwind classes', () => {
158
+ it('never builds an arbitrary-value utility from a template literal', () => {
159
+ // Tailwind scans source statically, so `h-[${SIZE}px]` is never
160
+ // generated. It type-checks, lints and builds, and does nothing.
161
+ const violations: string[] = []
162
+ for (const file of FILES) {
163
+ readCode(file).forEach((line, i) => {
164
+ if (/[a-z-]+-\[[^\]]*\$\{/.test(line)) {
165
+ violations.push(`${rel(file)}:${i + 1} — ${line.trim()}`)
166
+ }
167
+ })
168
+ }
169
+ expect(violations).toEqual([])
170
+ })
171
+ })
172
+
173
+ describe('undefined utility classes', () => {
174
+ // `animate-in`, `fade-in-0`, `zoom-in-95`, `slide-in-from-*` ship in
175
+ // tailwindcss-animate / tw-animate-css. Neither is a dependency of this
176
+ // repo, and Tailwind 4 core ships only spin/ping/pulse/bounce — so both
177
+ // of the kit's entrance animations referenced classes that had never
178
+ // been generated and had never run.
179
+ const ANIMATE_PLUGIN_CLASSES =
180
+ /\b(animate-in|animate-out|fade-in(-\d+)?|fade-out(-\d+)?|zoom-in(-\d+)?|zoom-out(-\d+)?|slide-in-from-\w+(-\d+)?|slide-out-to-\w+(-\d+)?)\b/
181
+
182
+ it('does not use tailwindcss-animate classes without the dependency', () => {
183
+ const violations: string[] = []
184
+ for (const file of FILES) {
185
+ readCode(file).forEach((line, i) => {
186
+ if (ANIMATE_PLUGIN_CLASSES.test(line)) {
187
+ violations.push(`${rel(file)}:${i + 1} — ${line.trim()}`)
188
+ }
189
+ })
190
+ }
191
+ expect(violations).toEqual([])
192
+ })
193
+ })
package/src/card.tsx CHANGED
@@ -203,7 +203,7 @@ function MetaItem({
203
203
  {icon}
204
204
  </span>
205
205
  )}
206
- <span className="truncate tnum">{children}</span>
206
+ <span className="truncate tabular-nums">{children}</span>
207
207
  </div>
208
208
  )
209
209
  }
@@ -0,0 +1,85 @@
1
+ // CollapsibleCard — a Card whose titled header toggles its body open/closed.
2
+ //
3
+ // Reviewing long specs means scrolling past sections you don't care about right
4
+ // now (a long Sitelinks list to reach Structured snippets, etc.). A collapsible
5
+ // titled card lets the reviewer fold sections away as they go. Built on the
6
+ // Card primitive; accessible (the title is a real button with aria-expanded)
7
+ // and honors prefers-reduced-motion.
8
+ //
9
+ // The collapse toggle is the chevron+title region only, so header `actions`
10
+ // (e.g. a "Suggest" button) stay independently clickable beside it — not nested
11
+ // inside the toggle button.
12
+ //
13
+ // Promoted from the SEM Spec lens per ADR-136 Decision 11 (the "2+ consumers ->
14
+ // promote" gate): the report tabs and the public share view both consume it.
15
+
16
+ import { useId, useState, type ReactNode } from 'react'
17
+ import { ChevronDown } from 'lucide-react'
18
+ import Card from './card'
19
+
20
+ export interface CollapsibleCardProps {
21
+ title: ReactNode
22
+ /** Optional leading icon (rendered before the title). */
23
+ icon?: ReactNode
24
+ /** Optional header content after the title (e.g. a count or subtitle). */
25
+ meta?: ReactNode
26
+ /** Right-aligned header controls, kept outside the collapse toggle. */
27
+ actions?: ReactNode
28
+ /** Start expanded (default) or collapsed. */
29
+ defaultOpen?: boolean
30
+ /** Body padding utility; pass '' for full-bleed bodies (e.g. tables). */
31
+ contentClassName?: string
32
+ children: ReactNode
33
+ }
34
+
35
+ export function CollapsibleCard({
36
+ title,
37
+ icon,
38
+ meta,
39
+ actions,
40
+ defaultOpen = true,
41
+ contentClassName = 'px-4 py-4',
42
+ children,
43
+ }: CollapsibleCardProps) {
44
+ const [open, setOpen] = useState(defaultOpen)
45
+ const bodyId = useId()
46
+
47
+ return (
48
+ <Card>
49
+ <div
50
+ className="flex items-center justify-between gap-3 px-4 py-3"
51
+ style={{
52
+ borderBottom: `1px solid ${open ? 'rgb(var(--border))' : 'transparent'}`,
53
+ }}
54
+ >
55
+ <button
56
+ type="button"
57
+ onClick={() => setOpen((o) => !o)}
58
+ aria-expanded={open}
59
+ aria-controls={bodyId}
60
+ className="flex min-w-0 flex-1 items-center gap-2 rounded-[var(--radius-sm)] py-0.5 text-left transition-colors focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]"
61
+ >
62
+ <ChevronDown
63
+ className="h-4 w-4 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none"
64
+ style={{
65
+ color: 'rgb(var(--text-tertiary))',
66
+ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)',
67
+ }}
68
+ aria-hidden="true"
69
+ />
70
+ {icon}
71
+ <span className="truncate text-[14px] font-semibold" style={{ color: 'rgb(var(--foreground))' }}>
72
+ {title}
73
+ </span>
74
+ {meta}
75
+ </button>
76
+ {actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
77
+ </div>
78
+ {open && (
79
+ <div id={bodyId} className={contentClassName}>
80
+ {children}
81
+ </div>
82
+ )}
83
+ </Card>
84
+ )
85
+ }
@@ -257,7 +257,14 @@ export function DataGridTableBody<
257
257
  return (
258
258
  <TableBody>
259
259
  {visibleRows.map((row, rowIndex) => (
260
- <TableRow key={row.id}>
260
+ // TableRow styles `data-[state=selected]`; without this attribute a
261
+ // checked row had no visual state at all — only the checkbox changed.
262
+ <TableRow
263
+ key={row.id}
264
+ {...(selectedRowIds.includes(row.id)
265
+ ? { 'data-state': 'selected' as const }
266
+ : {})}
267
+ >
261
268
  <TableCell
262
269
  className="h-10 px-0 text-center"
263
270
  style={{
@@ -276,7 +276,7 @@ export function DropdownMenuContent({
276
276
  'fixed z-[100] min-w-48 max-w-[min(28rem,calc(100vw-16px))]',
277
277
  'p-1.5 overflow-y-auto',
278
278
  'rounded-[var(--radius-lg)]',
279
- isPositioned && 'animate-in fade-in-0 zoom-in-95 duration-100',
279
+ isPositioned && 'ds-enter-pop',
280
280
  className,
281
281
  )}
282
282
  style={{
@@ -61,7 +61,7 @@ export function FloatingStatusBar({
61
61
  role="status"
62
62
  className={cn(
63
63
  'fixed bottom-4 left-1/2 z-30 flex -translate-x-1/2 items-center gap-3.5 px-3.5 py-2.5',
64
- 'motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2',
64
+ 'ds-enter-rise',
65
65
  className,
66
66
  )}
67
67
  style={{
@@ -265,13 +265,12 @@ export function FolderTreePicker({
265
265
  aria-controls={open ? popoverId : undefined}
266
266
  disabled={disabled}
267
267
  onClick={() => setOpen((o) => !o)}
268
- className={cn(
269
- 'btn btn-secondary justify-between w-full',
270
- error && 'is-error',
271
- className,
272
- )}
268
+ className={cn('btn btn-secondary justify-between w-full', className)}
273
269
  style={{
274
- borderColor: error ? 'rgb(var(--ring-error))' : undefined,
270
+ /* --ring-error is a box-shadow LIST, not a colour; rgb(var(--ring-error))
271
+ is invalid CSS and gets dropped, which left this error state inert.
272
+ The colour token is --destructive (what .input-shell.is-error uses). */
273
+ borderColor: error ? 'rgb(var(--destructive))' : undefined,
275
274
  }}
276
275
  >
277
276
  <span className="inline-flex items-center gap-2 min-w-0">
@@ -1,13 +1,25 @@
1
1
  /**
2
2
  * FrameStack — slate outer-frame wrapper for the shell/tray pattern.
3
3
  *
4
- * Single purpose: wrap 2+ inner cards as ONE visual group. Renders a
5
- * slate-tinted outer frame (--card-frame-bg, --border-card) holding
6
- * the children with 10 px outer padding and 10 px gap between them.
4
+ * Single purpose: wrap 2+ inner cards as ONE visual group. Renders an
5
+ * outer frame (--surface-frame via --card-frame-bg, --border-card)
6
+ * holding the children with a --tray-inset outer padding and a slightly
7
+ * larger gap between them.
8
+ *
9
+ * The padding is the SAME token <Card> uses for its own tray inset, so a
10
+ * framed group and a framed card have an identical edge. It was a
11
+ * hardcoded 10 px, which made the group frame visibly chunkier than every
12
+ * card beside it — the two patterns are the same idea and should not
13
+ * measure differently.
14
+ *
15
+ * Padding smaller than the gap is deliberate, not an oversight: it is
16
+ * what the corpus kits measure (AI Agent's shell is pad 4 / gap 10), and
17
+ * it reads as panes seated in a tight frame rather than cards floating in
18
+ * a loose one.
7
19
  *
8
20
  * ┌─────────────────────────────────┐ ← <FrameStack>
9
- * │ ┌─────────────────────────────┐ │ slate bg (--card-frame-bg)
10
- * │ │ Card / SectionCard 01 │ │ 10 px padding, 10 px gap
21
+ * │ ┌─────────────────────────────┐ │ frame bg (--card-frame-bg)
22
+ * │ │ Card / SectionCard 01 │ │ --tray-inset padding, 10 px gap
11
23
  * │ └─────────────────────────────┘ │ 1 px --border-card outline
12
24
  * │ ┌─────────────────────────────┐ │ --radius-xl (20 px) outer
13
25
  * │ │ Card / SectionCard 02 │ │
@@ -28,9 +40,8 @@
28
40
  * elements as direct page-background children with `gap-3`. That's
29
41
  * "free-floating cards," not the shell/tray pattern. Wrap them.
30
42
  *
31
- * Lens-level lock: `apps/workspace/src/lenses/meta-creative/CLAUDE.md`
32
- * §"The shell/tray pattern" — the Meta Creative Builder uses this
33
- * primitive for the 3-section form.
43
+ * Consumers: the Social Spec builder (BuilderForm, TacticBuilder) and the
44
+ * Brand Profile section groups.
34
45
  *
35
46
  * Token discipline: internal tokens (--card-frame-bg, --border-card)
36
47
  * are touched HERE per ADR-006 D2 — primitives live under
@@ -45,10 +56,16 @@ export interface FrameStackProps extends HTMLAttributes<HTMLDivElement> {
45
56
  * prototype's spec. Override only with a token value
46
57
  * (e.g. `'var(--space-3)'` for 12 px) — never a literal. */
47
58
  gap?: string
59
+ /** Outer padding. Defaults to `--tray-inset`, the same inset <Card>
60
+ * uses, so a framed group and a framed card share one edge. */
61
+ padding?: string
48
62
  }
49
63
 
50
64
  export const FrameStack = forwardRef<HTMLDivElement, FrameStackProps>(
51
- function FrameStack({ className, style, gap = '10px', children, ...rest }, ref) {
65
+ function FrameStack(
66
+ { className, style, gap = '10px', padding = 'var(--tray-inset)', children, ...rest },
67
+ ref,
68
+ ) {
52
69
  return (
53
70
  <div
54
71
  ref={ref}
@@ -58,7 +75,7 @@ export const FrameStack = forwardRef<HTMLDivElement, FrameStackProps>(
58
75
  background: 'rgb(var(--card-frame-bg))',
59
76
  border: '1px solid rgb(var(--border-card))',
60
77
  borderRadius: 'var(--radius-xl)',
61
- padding: '10px',
78
+ padding,
62
79
  gap,
63
80
  ...style,
64
81
  }}
@@ -343,8 +343,8 @@ const BANNER_TONE: Record<
343
343
  icon: 'rgb(var(--warning))',
344
344
  },
345
345
  destructive: {
346
- bg: 'rgb(var(--surface-overlay-soft))',
347
- border: 'rgb(var(--ring-error))',
346
+ bg: 'rgb(var(--destructive-bg))',
347
+ border: 'rgb(var(--destructive))',
348
348
  icon: 'rgb(var(--destructive))',
349
349
  },
350
350
  }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Brand platform glyphs — real mono (currentColor) marks for the ad platforms
3
+ * the Social Spec builds for, replacing approximate Lucide stand-ins (Ghost for
4
+ * Snapchat, Home for Nextdoor, Music for TikTok, Megaphone for Meta). ADR-135.
5
+ *
6
+ * `@lovett/ui` is the one allowed home for bespoke SVG definitions (CLAUDE.md
7
+ * §2/§6) — same rationale as `MicrosoftLogo`. Path data is extracted from the
8
+ * owner's MIT-licensed fork github.com/edwinlov3tt/logos-apps (upstream: the
9
+ * ln-dev7 "logos" collection); each glyph is normalized to a single-colour
10
+ * `currentColor` silhouette so it adapts to its context (active pill = white,
11
+ * inactive tab = grey) — full-colour marks would clash inside the red pill.
12
+ * Extraction choices were picked by visual review; Facebook, Messenger and
13
+ * TikTok silhouettes are true path-boolean derivations of the official marks
14
+ * (disc⊖f, bubble⊖bolt, glitch-layer union — computed offline with paper.js,
15
+ * baked here as static path data).
16
+ *
17
+ * Marks are trademarks of their platforms — used nominatively to refer to the
18
+ * platforms themselves, exactly like the platform-chrome in the ad previews.
19
+ */
20
+
21
+ export interface BrandIconProps {
22
+ /** Square box size in px; the glyph letterboxes inside (non-square viewBoxes
23
+ * centre via preserveAspectRatio). Default 16 — a tab/affix glyph. */
24
+ size?: number
25
+ className?: string
26
+ }
27
+
28
+ /** Meta brand glyph (mono, currentColor). */
29
+ export function BrandMeta({ size = 16, className }: BrandIconProps) {
30
+ return (
31
+ <svg
32
+ width={size}
33
+ height={size}
34
+ viewBox="-5.12 -5.12 266.24 180.28"
35
+ className={className}
36
+ aria-hidden="true"
37
+ focusable="false"
38
+ >
39
+ <path d="M27.651 112.136c0 9.775 2.146 17.28 4.95 21.82c3.677 5.947 9.16 8.466 14.751 8.466c7.211 0 13.808-1.79 26.52-19.372c10.185-14.092 22.186-33.874 30.26-46.275l13.675-21.01c9.499-14.591 20.493-30.811 33.1-41.806C161.196 4.985 172.298 0 183.47 0c18.758 0 36.625 10.87 50.3 31.257C248.735 53.584 256 81.707 256 110.729c0 17.253-3.4 29.93-9.187 39.946c-5.591 9.686-16.488 19.363-34.818 19.363v-27.616c15.695 0 19.612-14.422 19.612-30.927c0-23.52-5.484-49.623-17.564-68.273c-8.574-13.23-19.684-21.313-31.907-21.313c-13.22 0-23.859 9.97-35.815 27.75c-6.356 9.445-12.882 20.956-20.208 33.944l-8.066 14.289c-16.203 28.728-20.307 35.271-28.408 46.07c-14.2 18.91-26.324 26.076-42.287 26.076c-18.935 0-30.91-8.2-38.325-20.556C2.973 139.413 0 126.202 0 111.148z" fill="currentColor" />
40
+ <path d="M21.802 33.206C34.48 13.666 52.774 0 73.757 0C85.91 0 97.99 3.597 110.605 13.897c13.798 11.261 28.505 29.805 46.853 60.368l6.58 10.967c15.881 26.459 24.917 40.07 30.205 46.49c6.802 8.243 11.565 10.7 17.752 10.7c15.695 0 19.612-14.422 19.612-30.927l24.393-.766c0 17.253-3.4 29.93-9.187 39.946c-5.591 9.686-16.488 19.363-34.818 19.363c-11.395 0-21.49-2.475-32.654-13.007c-8.582-8.083-18.615-22.443-26.334-35.352l-22.96-38.352C118.528 64.08 107.96 49.73 101.845 43.23c-6.578-6.988-15.036-15.428-28.532-15.428c-10.923 0-20.2 7.666-27.963 19.39z" fill="currentColor" />
41
+ <path d="M73.312 27.802c-10.923 0-20.2 7.666-27.963 19.39c-10.976 16.568-17.698 41.245-17.698 64.944c0 9.775 2.146 17.28 4.95 21.82L9.027 149.482C2.973 139.413 0 126.202 0 111.148C0 83.772 7.514 55.24 21.802 33.206C34.48 13.666 52.774 0 73.757 0z" fill="currentColor" />
42
+ </svg>
43
+ )
44
+ }
45
+
46
+ /** Facebook brand glyph (mono, currentColor). */
47
+ export function BrandFacebook({ size = 16, className }: BrandIconProps) {
48
+ return (
49
+ <svg
50
+ width={size}
51
+ height={size}
52
+ viewBox="-5.12 -5.12 266.24 264.69"
53
+ className={className}
54
+ aria-hidden="true"
55
+ focusable="false"
56
+ >
57
+ <path d="M256 128C256 57.308 198.692 0 128 0S0 57.308 0 128c0 63.888 46.808 116.843 108 126.445V165H75.5v-37H108V99.8c0-32.08 19.11-49.8 48.348-49.8C170.352 50 185 52.5 185 52.5V84h-16.14C152.959 84 148 93.867 148 103.99V128h35.5l-5.675 37H148v89.445c61.192-9.602 108-62.556 108-126.445" fill="currentColor" />
58
+ </svg>
59
+ )
60
+ }
61
+
62
+ /** Instagram brand glyph (mono, currentColor). */
63
+ export function BrandInstagram({ size = 16, className }: BrandIconProps) {
64
+ return (
65
+ <svg
66
+ width={size}
67
+ height={size}
68
+ viewBox="-5.12 -5.12 266.24 266.24"
69
+ className={className}
70
+ aria-hidden="true"
71
+ focusable="false"
72
+ >
73
+ <path d="M128 23.064c34.177 0 38.225.13 51.722.745c12.48.57 19.258 2.655 23.769 4.408c5.974 2.322 10.238 5.096 14.717 9.575s7.253 8.743 9.575 14.717c1.753 4.511 3.838 11.289 4.408 23.768c.615 13.498.745 17.546.745 51.723s-.13 38.226-.745 51.723c-.57 12.48-2.655 19.257-4.408 23.768c-2.322 5.974-5.096 10.239-9.575 14.718s-8.743 7.253-14.717 9.574c-4.511 1.753-11.289 3.839-23.769 4.408c-13.495.616-17.543.746-51.722.746s-38.228-.13-51.723-.746c-12.48-.57-19.257-2.655-23.768-4.408c-5.974-2.321-10.239-5.095-14.718-9.574c-4.479-4.48-7.253-8.744-9.574-14.718c-1.753-4.51-3.839-11.288-4.408-23.768c-.616-13.497-.746-17.545-.746-51.723s.13-38.225.746-51.722c.57-12.48 2.655-19.258 4.408-23.769c2.321-5.974 5.095-10.238 9.574-14.717c4.48-4.48 8.744-7.253 14.718-9.575c4.51-1.753 11.288-3.838 23.768-4.408c13.497-.615 17.545-.745 51.723-.745M128 0C93.237 0 88.878.147 75.226.77c-13.625.622-22.93 2.786-31.071 5.95c-8.418 3.271-15.556 7.648-22.672 14.764S9.991 35.738 6.72 44.155C3.555 52.297 1.392 61.602.77 75.226C.147 88.878 0 93.237 0 128s.147 39.122.77 52.774c.622 13.625 2.785 22.93 5.95 31.071c3.27 8.417 7.647 15.556 14.763 22.672s14.254 11.492 22.672 14.763c8.142 3.165 17.446 5.328 31.07 5.95c13.653.623 18.012.77 52.775.77s39.122-.147 52.774-.77c13.624-.622 22.929-2.785 31.07-5.95c8.418-3.27 15.556-7.647 22.672-14.763s11.493-14.254 14.764-22.672c3.164-8.142 5.328-17.446 5.95-31.07c.623-13.653.77-18.012.77-52.775s-.147-39.122-.77-52.774c-.622-13.624-2.786-22.929-5.95-31.07c-3.271-8.418-7.648-15.556-14.764-22.672S220.262 9.99 211.845 6.72c-8.142-3.164-17.447-5.328-31.071-5.95C167.122.147 162.763 0 128 0m0 62.27c-36.302 0-65.73 29.43-65.73 65.73s29.428 65.73 65.73 65.73c36.301 0 65.73-29.428 65.73-65.73c0-36.301-29.429-65.73-65.73-65.73m0 108.397c-23.564 0-42.667-19.103-42.667-42.667S104.436 85.333 128 85.333s42.667 19.103 42.667 42.667s-19.103 42.667-42.667 42.667m83.686-110.994c0 8.484-6.876 15.36-15.36 15.36s-15.36-6.876-15.36-15.36s6.877-15.36 15.36-15.36s15.36 6.877 15.36 15.36" fill="currentColor" />
74
+ </svg>
75
+ )
76
+ }
77
+
78
+ /** Messenger brand glyph (mono, currentColor). */
79
+ export function BrandMessenger({ size = 16, className }: BrandIconProps) {
80
+ return (
81
+ <svg
82
+ width={size}
83
+ height={size}
84
+ viewBox="-5.12 -5.12 266.24 266.24"
85
+ className={className}
86
+ aria-hidden="true"
87
+ focusable="false"
88
+ >
89
+ <path d="M128,0c-72.106,0 -128,52.818 -128,124.16c0,37.317 15.293,69.562 40.2,91.835c2.09,1.871 3.352,4.493 3.438,7.298l0.697,22.77c0.223,7.262 7.724,11.988 14.37,9.054l25.406,-11.217c2.15335,-0.94916 4.56829,-1.12613 6.837,-0.501c11.675,3.21 24.1,4.92 37.052,4.92c72.106,0 128,-52.818 128,-124.16c0,-71.342 -55.894,-124.159 -128,-124.159 M51.137,160.47l37.6,-59.653c5.98,-9.49 18.788,-11.853 27.762,-5.123l29.905,22.43c2.744,2.05808 6.52006,2.04706 9.252,-0.027l40.388,-30.652c5.39,-4.091 12.428,2.36 8.82,8.085l-37.6,59.654c-5.981,9.489 -18.79,11.852 -27.763,5.122l-29.906,-22.43c-2.74359,-2.05707 -6.51846,-2.04605 -9.25,0.027l-40.39,30.652c-5.39,4.09 -12.427,-2.36 -8.818,-8.085" fill="currentColor" />
90
+ </svg>
91
+ )
92
+ }
93
+
94
+ /** LinkedIn brand glyph (mono, currentColor). */
95
+ export function BrandLinkedIn({ size = 16, className }: BrandIconProps) {
96
+ return (
97
+ <svg
98
+ width={size}
99
+ height={size}
100
+ viewBox="1.44 -0.56 29.12 29.12"
101
+ className={className}
102
+ aria-hidden="true"
103
+ focusable="false"
104
+ >
105
+ <path d="M8.268 28H2.463V9.306h5.805zM5.362 6.756C3.506 6.756 2 5.218 2 3.362a3.362 3.362 0 0 1 6.724 0c0 1.856-1.506 3.394-3.362 3.394M29.994 28h-5.792v-9.1c0-2.169-.044-4.95-3.018-4.95c-3.018 0-3.481 2.356-3.481 4.794V28h-5.799V9.306h5.567v2.55h.081c.775-1.469 2.668-3.019 5.492-3.019c5.875 0 6.955 3.869 6.955 8.894V28z" fill="currentColor" />
106
+ </svg>
107
+ )
108
+ }
109
+
110
+ /** Nextdoor brand glyph (mono, currentColor). */
111
+ export function BrandNextdoor({ size = 16, className }: BrandIconProps) {
112
+ return (
113
+ <svg
114
+ width={size}
115
+ height={size}
116
+ viewBox="-1.28 5.29 66.56 53.4"
117
+ className={className}
118
+ aria-hidden="true"
119
+ focusable="false"
120
+ >
121
+ <path d="M0 25.993c.285-.085.56-.2.82-.343l9.538-5.894c.166-.125.325-.26.477-.4V6.708h10.168v6.524L32 6.574l32 19.6-5.265 8.622-5.475-3.338h-.172a.21.21 0 0 0 0 .114v25.58c-1.068.267-38.343.362-41.967.134V56.8q0-12.533 0-25.104l-.114-.23-5.76 3.32L3.1 31.373c-.916-1.488-1.908-2.976-2.766-4.464A1.91 1.91 0 0 0 0 26.508z" fill="currentColor" />
122
+ </svg>
123
+ )
124
+ }
125
+
126
+ /** Pinterest brand glyph (mono, currentColor). */
127
+ export function BrandPinterest({ size = 16, className }: BrandIconProps) {
128
+ return (
129
+ <svg
130
+ width={size}
131
+ height={size}
132
+ viewBox="3.38 -0.22 25.25 32.44"
133
+ className={className}
134
+ aria-hidden="true"
135
+ focusable="false"
136
+ >
137
+ <path d="M16.75.406C10.337.406 4 4.681 4 11.6c0 4.4 2.475 6.9 3.975 6.9c.619 0 .975-1.725.975-2.212c0-.581-1.481-1.819-1.481-4.238c0-5.025 3.825-8.588 8.775-8.588c4.256 0 7.406 2.419 7.406 6.863c0 3.319-1.331 9.544-5.644 9.544c-1.556 0-2.888-1.125-2.888-2.737c0-2.363 1.65-4.65 1.65-7.088c0-4.137-5.869-3.387-5.869 1.613c0 1.05.131 2.212.6 3.169c-.863 3.713-2.625 9.244-2.625 13.069c0 1.181.169 2.344.281 3.525c.212.238.106.213.431.094c3.15-4.313 3.038-5.156 4.463-10.8c.769 1.463 2.756 2.25 4.331 2.25c6.637 0 9.619-6.469 9.619-12.3c0-6.206-5.363-10.256-11.25-10.256z" fill="currentColor" />
138
+ </svg>
139
+ )
140
+ }
141
+
142
+ /** Snapchat brand glyph (mono, currentColor). */
143
+ export function BrandSnapchat({ size = 16, className }: BrandIconProps) {
144
+ return (
145
+ <svg
146
+ width={size}
147
+ height={size}
148
+ viewBox="208.68 112.18 391.9 368.85"
149
+ className={className}
150
+ aria-hidden="true"
151
+ focusable="false"
152
+ >
153
+ <path d="M407.001,473.488c-1.068,0-2.087-0.039-2.862-0.076c-0.615,0.053-1.25,0.076-1.886,0.076 c-22.437,0-37.439-10.607-50.678-19.973c-9.489-6.703-18.438-13.031-28.922-14.775c-5.149-0.854-10.271-1.287-15.22-1.287 c-8.917,0-15.964,1.383-21.109,2.389c-3.166,0.617-5.896,1.148-8.006,1.148c-2.21,0-4.895-0.49-6.014-4.311 c-0.887-3.014-1.523-5.934-2.137-8.746c-1.536-7.027-2.65-11.316-5.281-11.723c-28.141-4.342-44.768-10.738-48.08-18.484 c-0.347-0.814-0.541-1.633-0.584-2.443c-0.129-2.309,1.501-4.334,3.777-4.711c22.348-3.68,42.219-15.492,59.064-35.119 c13.049-15.195,19.457-29.713,20.145-31.316c0.03-0.072,0.065-0.148,0.101-0.217c3.247-6.588,3.893-12.281,1.926-16.916 c-3.626-8.551-15.635-12.361-23.58-14.882c-1.976-0.625-3.845-1.217-5.334-1.808c-7.043-2.782-18.626-8.66-17.083-16.773 c1.124-5.916,8.949-10.036,15.273-10.036c1.756,0,3.312,0.308,4.622,0.923c7.146,3.348,13.575,5.045,19.104,5.045 c6.876,0,10.197-2.618,11-3.362c-0.198-3.668-0.44-7.546-0.674-11.214c0-0.004-0.005-0.048-0.005-0.048 c-1.614-25.675-3.627-57.627,4.546-75.95c24.462-54.847,76.339-59.112,91.651-59.112c0.408,0,6.674-0.062,6.674-0.062 c0.283-0.005,0.59-0.009,0.908-0.009c15.354,0,67.339,4.27,91.816,59.15c8.173,18.335,6.158,50.314,4.539,76.016l-0.076,1.23 c-0.222,3.49-0.427,6.793-0.6,9.995c0.756,0.696,3.795,3.096,9.978,3.339c5.271-0.202,11.328-1.891,17.998-5.014 c2.062-0.968,4.345-1.169,5.895-1.169c2.343,0,4.727,0.456,6.714,1.285l0.106,0.041c5.66,2.009,9.367,6.024,9.447,10.242 c0.071,3.932-2.851,9.809-17.223,15.485c-1.472,0.583-3.35,1.179-5.334,1.808c-7.952,2.524-19.951,6.332-23.577,14.878 c-1.97,4.635-1.322,10.326,1.926,16.912c0.036,0.072,0.067,0.145,0.102,0.221c1,2.344,25.205,57.535,79.209,66.432 c2.275,0.379,3.908,2.406,3.778,4.711c-0.048,0.828-0.248,1.656-0.598,2.465c-3.289,7.703-19.915,14.09-48.064,18.438 c-2.642,0.408-3.755,4.678-5.277,11.668c-0.63,2.887-1.271,5.717-2.146,8.691c-0.819,2.797-2.641,4.164-5.567,4.164h-0.441 c-1.905,0-4.604-0.346-8.008-1.012c-5.95-1.158-12.623-2.236-21.109-2.236c-4.948,0-10.069,0.434-15.224,1.287 c-10.473,1.744-19.421,8.062-28.893,14.758C444.443,462.88,429.436,473.488,407.001,473.488" fill="currentColor" />
154
+ </svg>
155
+ )
156
+ }
157
+
158
+ /** TikTok brand glyph (mono, currentColor). */
159
+ export function BrandTikTok({ size = 16, className }: BrandIconProps) {
160
+ return (
161
+ <svg
162
+ width={size}
163
+ height={size}
164
+ viewBox="-5.79 -5.79 267.56 300.84"
165
+ className={className}
166
+ aria-hidden="true"
167
+ focusable="false"
168
+ >
169
+ <path d="M189.722,104.42243l0,96.99357c0,48.525 -39.357,87.857 -87.905,87.857c-24.62405,0 -46.88248,-10.1174 -62.84421,-26.42813c-0.00093,-0.00062 -0.00186,-0.00125 -0.00279,-0.00187c-0.00254,-0.00259 -0.00508,-0.00519 -0.00761,-0.00778c-23.49917,-15.76977 -38.96239,-42.57494 -38.96239,-72.99022c0,-48.518 39.355,-87.852 87.905,-87.852c4.03423,-0.001 8.06385,0.2723 12.061,0.818l0,10.76898c4.64967,-0.09894 9.30133,0.17007 13.911,0.80102v48.593c-3.94487,-1.25017 -8.05877,-1.88528 -12.197,-1.883c-22.209,0 -40.21,17.994 -40.21,40.186c-0.00942,6.11418 1.38223,12.11209 4.025,17.54894l-17.941,-29.11894c0,-6.79366 1.68644,-13.19354 4.66379,-18.8042c-2.97797,5.61107 -4.66479,12.01139 -4.66479,18.8052c0,15.692 8.998,29.28 22.118,35.898c0.00103,0.00136 0.00207,0.00271 0.0031,0.00407c5.60932,2.82644 11.80469,4.29426 18.0859,4.28493c22.162,0 40.132,-17.92 40.208,-40.05v-189.845h47.834v6.113c0.1689,1.82808 0.41177,3.64857 0.728,5.457l-34.643,0.001v189.844c-0.078,22.132 -18.05,40.05 -40.21,40.05c-5.67604,0 -11.07698,-1.17525 -15.97293,-3.29552c4.89621,2.12093 10.2975,3.29652 15.97393,3.29652c22.161,0 40.131,-17.92 40.21,-40.052v-189.843l47.832,0v6.113c1.356,14.892 7.624,28.362 17.17,38.785h-0.001c10.55395,6.58342 22.74707,10.06504 35.186,10.047v10.116c4.57579,0.96983 9.24056,1.45782 13.918,1.456v47.53c-24.71212,0 -47.59337,-7.85144 -66.271,-21.19557zM99.964,151.40168c0.00033,0.00011 0.00067,0.00021 0.001,0.00032l0,-37.82098c-0.00033,0.00001 -0.00067,0.00001 -0.001,0.00002zM242.075,114.045v-0.001c-24.712,0 -47.589,-7.851 -66.272,-21.195v0.001c18.682,13.344 41.561,21.195 66.272,21.195zM69.673,225.607c-1.62409,-2.12792 -3.02095,-4.40072 -4.178,-6.78106zM228.74787,72.27412c3.67381,1.65002 7.52454,2.97698 11.51604,3.94463c-3.9686,-0.96405 -7.82207,-2.2874 -11.51604,-3.94463z" fill="currentColor" />
170
+ </svg>
171
+ )
172
+ }
173
+
174
+ /** Keyed map for registry-style resolution (e.g. the Social Spec tactic icons). */
175
+ export const BRAND_ICONS = {
176
+ meta: BrandMeta,
177
+ facebook: BrandFacebook,
178
+ instagram: BrandInstagram,
179
+ messenger: BrandMessenger,
180
+ linkedin: BrandLinkedIn,
181
+ nextdoor: BrandNextdoor,
182
+ pinterest: BrandPinterest,
183
+ snapchat: BrandSnapchat,
184
+ tiktok: BrandTikTok,
185
+ } as const
186
+
187
+ export type BrandIconKey = keyof typeof BRAND_ICONS