minka-ds 0.3.12 → 0.4.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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "minka-ds",
3
- "version": "0.3.12",
3
+ "version": "0.4.1",
4
4
  "description": "Minka product design system — tokenized component library",
5
5
  "license": "MIT",
6
6
  "files": [
7
7
  "src",
8
- "tokens"
8
+ "tokens",
9
+ "!src/textures/*.svg"
9
10
  ],
10
11
  "exports": {
11
12
  ".": "./src/index.ts",
@@ -17,6 +17,8 @@ interface AvatarProps {
17
17
  size?: "sm" | "md" | "lg"
18
18
  /** Background for the initials state. Defaults to a brand color. */
19
19
  background?: string
20
+ /** Foreground (initials) color. Defaults to the inverse text token. */
21
+ color?: string
20
22
  className?: string
21
23
  }
22
24
 
@@ -25,7 +27,7 @@ function deriveInitials(name?: string): string {
25
27
  return name.trim().split(/\s+/).map(p => p[0]).join("").slice(0, 2).toUpperCase()
26
28
  }
27
29
 
28
- function Avatar({ src, name, initials, size = "md", background, className }: AvatarProps) {
30
+ function Avatar({ src, name, initials, size = "md", background, color, className }: AvatarProps) {
29
31
  return (
30
32
  <div
31
33
  data-slot="avatar"
@@ -34,7 +36,10 @@ function Avatar({ src, name, initials, size = "md", background, className }: Ava
34
36
  SIZE[size],
35
37
  className
36
38
  )}
37
- style={{ background: src ? undefined : (background ?? "var(--color-brand-blue)") }}
39
+ style={{
40
+ background: src ? undefined : (background ?? "var(--color-brand-blue)"),
41
+ ...(color ? { color } : {}),
42
+ }}
38
43
  >
39
44
  {src
40
45
  ? <img src={src} alt={name ?? ""} className="size-full object-cover" />
@@ -47,6 +47,44 @@ function DialogOverlay({
47
47
  )
48
48
  }
49
49
 
50
+ type PanelPlacement = "side" | "top"
51
+
52
+ /**
53
+ * Optional contextual panel rendered inside DialogContent. Holds supporting
54
+ * media or guidance (illustration, brand texture, action summary, help text).
55
+ * Place it as a direct child of DialogContent; the content adapts its layout.
56
+ *
57
+ * By default the panel bleeds to the dialog edges. Pass `inset` to float it
58
+ * inside the dialog with an 8px frame and rounded corners.
59
+ */
60
+ function DialogPanel({
61
+ className,
62
+ placement = "side",
63
+ inset = false,
64
+ ...props
65
+ }: React.ComponentProps<"div"> & { placement?: PanelPlacement; inset?: boolean }) {
66
+ return (
67
+ <div
68
+ data-slot="dialog-panel"
69
+ data-placement={placement}
70
+ data-inset={inset || undefined}
71
+ className={cn(
72
+ "relative flex flex-col justify-center bg-[var(--color-bg-canvas)]",
73
+ // inset panels are tighter — the 8px frame already adds breathing room
74
+ inset ? "p-4" : "p-6",
75
+ // side: a column down the left; top: a banner across the top
76
+ placement === "side" ? "shrink-0 sm:w-2/5" : "min-h-32",
77
+ // inset: float inside the dialog with an 8px frame on the dialog-facing
78
+ // edges only — the interior edge stays flush, the body padding separates
79
+ inset && "overflow-hidden [border-radius:var(--radius-card)]",
80
+ inset && (placement === "side" ? "ml-2 my-2" : "mt-2 mx-2"),
81
+ className
82
+ )}
83
+ {...props}
84
+ />
85
+ )
86
+ }
87
+
50
88
  function DialogContent({
51
89
  className,
52
90
  children,
@@ -57,31 +95,66 @@ function DialogContent({
57
95
  showCloseButton?: boolean
58
96
  container?: HTMLElement | null
59
97
  }) {
98
+ // Detect an optional DialogPanel child and split it out from the body.
99
+ const childArray = React.Children.toArray(children)
100
+ const panel = childArray.find(
101
+ (c): c is React.ReactElement<{ placement?: PanelPlacement; children?: React.ReactNode }> =>
102
+ React.isValidElement(c) && (c.type as { displayName?: string })?.displayName === "DialogPanel"
103
+ )
104
+ const body = childArray.filter(c => c !== panel)
105
+ const placement = panel?.props.placement ?? "side"
106
+ const hasPanel = Boolean(panel)
107
+
108
+ const closeButton = showCloseButton && (
109
+ <DialogPrimitive.Close asChild>
110
+ <Button
111
+ data-slot="dialog-close"
112
+ variant="ghost"
113
+ size="icon-sm"
114
+ aria-label="Close"
115
+ className="absolute top-5 right-5 z-10 translate-y-[5px] text-current opacity-70 hover:opacity-100"
116
+ >
117
+ <XIcon />
118
+ </Button>
119
+ </DialogPrimitive.Close>
120
+ )
121
+
60
122
  return (
61
123
  <DialogPortal container={container}>
62
124
  <DialogOverlay />
63
125
  <DialogPrimitive.Content
64
126
  data-slot="dialog-content"
127
+ data-panel={hasPanel ? placement : undefined}
65
128
  className={cn(
66
- "fixed top-[50%] left-[50%] [z-index:var(--z-modal)] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 [border-radius:var(--radius-modal)] border border-[var(--color-border-default)] bg-[var(--color-bg-overlay)] p-6 shadow-[var(--shadow-modal)] duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
129
+ "fixed top-[50%] left-[50%] [z-index:var(--z-modal)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-hidden [border-radius:var(--radius-modal)] border border-[var(--color-border-default)] bg-[var(--color-bg-overlay)] shadow-[var(--shadow-modal)] duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
130
+ // layout: plain (padded grid) vs panelled (flex split, panel bleeds to edges)
131
+ hasPanel
132
+ ? placement === "side"
133
+ ? "flex flex-col sm:flex-row sm:max-w-2xl"
134
+ : "flex flex-col"
135
+ : "grid gap-4 p-5",
67
136
  className
68
137
  )}
69
138
  {...props}
70
139
  >
71
- {children}
72
- {showCloseButton && (
73
- <DialogPrimitive.Close
74
- data-slot="dialog-close"
75
- className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
76
- >
77
- <XIcon />
78
- <span className="sr-only">Close</span>
79
- </DialogPrimitive.Close>
140
+ {/* top: close lives inside the panel so it inherits the panel's foreground */}
141
+ {placement === "top" && panel
142
+ ? React.cloneElement(panel, {}, panel.props.children, closeButton)
143
+ : panel}
144
+ {hasPanel ? (
145
+ <div data-slot="dialog-body" className="relative flex flex-1 flex-col gap-4 p-5">
146
+ {body}
147
+ </div>
148
+ ) : (
149
+ body
80
150
  )}
151
+ {/* side / no panel: close sits over the content body */}
152
+ {placement !== "top" && closeButton}
81
153
  </DialogPrimitive.Content>
82
154
  </DialogPortal>
83
155
  )
84
156
  }
157
+ DialogPanel.displayName = "DialogPanel"
85
158
 
86
159
  function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
87
160
  return (
@@ -127,7 +200,7 @@ function DialogTitle({
127
200
  return (
128
201
  <DialogPrimitive.Title
129
202
  data-slot="dialog-title"
130
- className={cn("text-heading-4", className)}
203
+ className={cn("text-heading-2-serif", className)}
131
204
  {...props}
132
205
  />
133
206
  )
@@ -154,6 +227,7 @@ export {
154
227
  DialogFooter,
155
228
  DialogHeader,
156
229
  DialogOverlay,
230
+ DialogPanel,
157
231
  DialogPortal,
158
232
  DialogTitle,
159
233
  DialogTrigger,
@@ -0,0 +1,202 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ export interface FlowNode {
6
+ /** Node label (party name). */
7
+ name: string
8
+ /** Current balance; null when hidden (e.g. balance not shown). */
9
+ current: number | null
10
+ /** Projected balance after the movement; null when no amount yet or hidden. */
11
+ after: number | null
12
+ /** Shown in place of a balance when `current` is null (e.g. "Master balance"). */
13
+ subtitle?: string
14
+ /** True when this node is an unfilled slot (e.g. nothing selected yet). */
15
+ empty?: boolean
16
+ }
17
+
18
+ type FormatFn = (n: number) => string
19
+ const defaultFormat: FormatFn = (n) => "$" + n.toLocaleString("en-US")
20
+
21
+ /**
22
+ * Exact-dash rounded border via an SVG rect overlay (CSS border-dashed has a
23
+ * fixed dash pattern that can't be sized). 6px dash / 6px gap. Fills its
24
+ * relatively-positioned parent.
25
+ */
26
+ function DashedBorder({ radius = 10 }: { radius?: number }) {
27
+ return (
28
+ <svg className="pointer-events-none absolute inset-0 size-full" aria-hidden>
29
+ <rect
30
+ x="0.75" y="0.75"
31
+ width="100%" height="100%"
32
+ rx={radius} ry={radius}
33
+ fill="none"
34
+ stroke="var(--color-border-default)"
35
+ strokeWidth="1.5"
36
+ strokeDasharray="6 6"
37
+ style={{ width: "calc(100% - 1.5px)", height: "calc(100% - 1.5px)" }}
38
+ />
39
+ </svg>
40
+ )
41
+ }
42
+
43
+ export interface FlowDiagramProps {
44
+ /** Top node (fixed position). */
45
+ top: FlowNode
46
+ /** Bottom node (fixed position). */
47
+ bottom: FlowNode
48
+ /** The amount flowing; 0 = idle/empty state. */
49
+ amount: number
50
+ /** Flow direction — arrow + sheen travel this way. */
51
+ direction: "up" | "down"
52
+ /** Unit shown beside the amount. */
53
+ currency?: string
54
+ /** Custom amount/balance formatter (defaults to "$" + en-US grouping). */
55
+ format?: FormatFn
56
+ /** Optional content rendered below the diagram (e.g. a reference pill). */
57
+ footer?: React.ReactNode
58
+ }
59
+
60
+ /**
61
+ * Vertical flow diagram for a value movement between two parties. Positions are
62
+ * FIXED (`top` / `bottom`); only the `direction` changes which way the arrow
63
+ * points and the light sheen travels. Each node shows its current balance,
64
+ * which demotes to a "was …" caption as the new (after) balance slides in once
65
+ * an amount is entered. Unfilled nodes render as a dashed empty slot. Neutral
66
+ * styling — suited to neutral transfers.
67
+ */
68
+ function FlowDiagram({ top, bottom, amount, direction, currency = "COP", format = defaultFormat, footer }: FlowDiagramProps) {
69
+ const active = amount > 0
70
+ const glintKey = active ? amount : 0
71
+
72
+ return (
73
+ <div data-slot="flow-diagram" className="flex flex-col items-center">
74
+ <style>{`
75
+ @keyframes flow-slide-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
76
+ @keyframes flow-pop { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
77
+ `}</style>
78
+
79
+ <FlowNodeCard key={top.empty ? "top-empty" : `top-${top.name}`} node={top} active={active} format={format} pop />
80
+
81
+ <Connector active={active} direction={direction} amount={amount} currency={currency} format={format} glintKey={glintKey} />
82
+
83
+ <FlowNodeCard key={bottom.empty ? "bottom-empty" : `bottom-${bottom.name}`} node={bottom} active={active} format={format} pop />
84
+
85
+ {footer && <div className="mt-4">{footer}</div>}
86
+ </div>
87
+ )
88
+ }
89
+
90
+ // Full connector height: enough for top gap + badge + bottom gap.
91
+ const CONNECTOR_H = 80
92
+
93
+ function Connector({
94
+ active, direction, amount, currency, format, glintKey,
95
+ }: {
96
+ active: boolean
97
+ direction: "up" | "down"
98
+ amount: number
99
+ currency: string
100
+ format: FormatFn
101
+ glintKey: number
102
+ }) {
103
+ const gid = React.useId()
104
+ const H = CONNECTOR_H
105
+ const headUp = direction === "up"
106
+
107
+ const head = headUp ? "M2 6 L7 0 L12 6" : `M2 ${H - 6} L7 ${H} L12 ${H - 6}`
108
+ const shapes = (
109
+ <>
110
+ <line x1="7" y1="0" x2="7" y2={H} />
111
+ <path d={head} />
112
+ </>
113
+ )
114
+
115
+ return (
116
+ <div className="relative flex items-center justify-center my-1" style={{ height: H }}>
117
+ <svg width="14" height={H} viewBox={`0 0 14 ${H}`} className="block overflow-visible" aria-hidden>
118
+ <g stroke={active ? "var(--color-text-default)" : "var(--color-border-strong)"} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none">
119
+ {shapes}
120
+ </g>
121
+ {active && (
122
+ <>
123
+ <defs>
124
+ <linearGradient id={gid} gradientUnits="userSpaceOnUse" x1="7" y1="0" x2="7" y2="18">
125
+ <stop offset="0%" stopColor="var(--color-bg-raised)" stopOpacity="0" />
126
+ <stop offset="50%" stopColor="var(--color-bg-raised)" stopOpacity="0.5" />
127
+ <stop offset="100%" stopColor="var(--color-bg-raised)" stopOpacity="0" />
128
+ <animate attributeName="y1" values={direction === "down" ? `-20;${H}` : `${H};-20`} dur="2.4s" repeatCount="indefinite" />
129
+ <animate attributeName="y2" values={direction === "down" ? `-2;${H + 18}` : `${H + 18};-2`} dur="2.4s" repeatCount="indefinite" />
130
+ </linearGradient>
131
+ </defs>
132
+ <g stroke={`url(#${gid})`} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none">
133
+ {shapes}
134
+ </g>
135
+ </>
136
+ )}
137
+ </svg>
138
+
139
+ <span
140
+ key={glintKey}
141
+ className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 px-2.5 py-1 [border-radius:var(--radius-badge)] text-label-mono transition-colors duration-300 ${
142
+ active
143
+ ? "bg-[var(--color-bg-inverted)] text-[var(--color-text-inverse)]"
144
+ : "bg-[var(--color-bg-base)] text-[var(--color-text-hint)]"
145
+ }`}
146
+ >
147
+ {!active && <DashedBorder radius={16} />}
148
+ {active ? format(amount) : format(0)}
149
+ <span className={`text-caption ml-1 ${active ? "text-[var(--color-text-inverse-muted)]" : "text-[var(--color-text-muted)]"}`}>{currency}</span>
150
+ </span>
151
+ </div>
152
+ )
153
+ }
154
+
155
+ function FlowNodeCard({ node, active, format, pop }: { node: FlowNode; active: boolean; format: FormatFn; pop?: boolean }) {
156
+ const showAfter = active && node.after !== null
157
+ const overdrawn = node.after !== null && node.after < 0
158
+
159
+ if (node.empty) {
160
+ return (
161
+ <div className="relative flex flex-col items-center justify-center gap-1 [border-radius:var(--radius-card)] bg-[var(--color-bg-base)] px-5 py-3 text-center min-w-[180px] min-h-[64px]">
162
+ <DashedBorder radius={10} />
163
+ <span className="text-body-sm text-[var(--color-text-hint)]">{node.name}</span>
164
+ </div>
165
+ )
166
+ }
167
+
168
+ return (
169
+ <div
170
+ className="flex flex-col items-center gap-1 [border-radius:var(--radius-card)] border border-[var(--color-border-default)] bg-[var(--color-bg-raised)] px-5 py-3 text-center min-w-[180px]"
171
+ style={pop ? { animation: "flow-pop .18s cubic-bezier(0.16,1,0.3,1) both" } : undefined}
172
+ >
173
+ <span className="text-body-sm text-[var(--color-text-default)]">{node.name}</span>
174
+
175
+ {node.current === null ? (
176
+ node.subtitle ? <span className="text-caption text-[var(--color-text-muted)]">{node.subtitle}</span> : null
177
+ ) : (
178
+ <div className="flex flex-col items-center leading-none">
179
+ <span
180
+ className={`origin-bottom transition-all duration-300 ${
181
+ showAfter
182
+ ? "scale-[0.78] text-caption text-[var(--color-text-muted)] mb-0.5"
183
+ : "text-label-mono text-[var(--color-text-default)]"
184
+ }`}
185
+ >
186
+ {showAfter ? `was ${format(node.current)}` : format(node.current)}
187
+ </span>
188
+ {showAfter && (
189
+ <span
190
+ className={`text-label-mono ${overdrawn ? "text-[var(--color-feedback-error)]" : "text-[var(--color-text-default)]"}`}
191
+ style={{ animation: "flow-slide-in .3s ease both" }}
192
+ >
193
+ {format(node.after!)}
194
+ </span>
195
+ )}
196
+ </div>
197
+ )}
198
+ </div>
199
+ )
200
+ }
201
+
202
+ export { FlowDiagram }
@@ -0,0 +1,106 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../lib/utils"
5
+
6
+ interface InputOTPProps {
7
+ /** Number of digit boxes. */
8
+ length?: number
9
+ value: string
10
+ onChange: (value: string) => void
11
+ /** Fires when all boxes are filled. */
12
+ onComplete?: (value: string) => void
13
+ invalid?: boolean
14
+ disabled?: boolean
15
+ autoFocus?: boolean
16
+ className?: string
17
+ }
18
+
19
+ function InputOTP({
20
+ length = 6,
21
+ value,
22
+ onChange,
23
+ onComplete,
24
+ invalid,
25
+ disabled,
26
+ autoFocus,
27
+ className,
28
+ }: InputOTPProps) {
29
+ const refs = React.useRef<(HTMLInputElement | null)[]>([])
30
+ const digits = Array.from({ length }, (_, i) => value[i] ?? "")
31
+
32
+ function setAt(index: number, char: string) {
33
+ const next = digits.slice()
34
+ next[index] = char
35
+ const joined = next.join("").slice(0, length)
36
+ onChange(joined)
37
+ if (joined.length === length) onComplete?.(joined)
38
+ }
39
+
40
+ function handleChange(index: number, raw: string) {
41
+ const char = raw.replace(/\D/g, "").slice(-1) // keep last typed digit
42
+ if (!char) return
43
+ setAt(index, char)
44
+ if (index < length - 1) refs.current[index + 1]?.focus()
45
+ }
46
+
47
+ function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
48
+ if (e.key === "Backspace") {
49
+ e.preventDefault()
50
+ if (digits[index]) {
51
+ setAt(index, "")
52
+ } else if (index > 0) {
53
+ refs.current[index - 1]?.focus()
54
+ setAt(index - 1, "")
55
+ }
56
+ } else if (e.key === "ArrowLeft" && index > 0) {
57
+ e.preventDefault()
58
+ refs.current[index - 1]?.focus()
59
+ } else if (e.key === "ArrowRight" && index < length - 1) {
60
+ e.preventDefault()
61
+ refs.current[index + 1]?.focus()
62
+ }
63
+ }
64
+
65
+ function handlePaste(e: React.ClipboardEvent) {
66
+ e.preventDefault()
67
+ const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length)
68
+ if (!pasted) return
69
+ onChange(pasted)
70
+ if (pasted.length === length) onComplete?.(pasted)
71
+ refs.current[Math.min(pasted.length, length - 1)]?.focus()
72
+ }
73
+
74
+ return (
75
+ <div data-slot="input-otp" className={cn("flex items-center gap-2", className)} onPaste={handlePaste}>
76
+ {digits.map((d, i) => (
77
+ <input
78
+ key={i}
79
+ ref={el => { refs.current[i] = el }}
80
+ type="text"
81
+ inputMode="numeric"
82
+ autoComplete={i === 0 ? "one-time-code" : "off"}
83
+ maxLength={1}
84
+ value={d}
85
+ disabled={disabled}
86
+ autoFocus={autoFocus && i === 0}
87
+ aria-invalid={invalid || undefined}
88
+ aria-label={`Digit ${i + 1}`}
89
+ onChange={e => handleChange(i, e.target.value)}
90
+ onKeyDown={e => handleKeyDown(i, e)}
91
+ onFocus={e => e.target.select()}
92
+ className={cn(
93
+ "size-11 text-center text-heading-4 [border-radius:var(--radius-input)] border bg-[var(--color-bg-raised)] outline-none transition-[color,box-shadow]",
94
+ "border-[var(--color-border-default)]",
95
+ "focus-visible:border-[var(--color-border-focus)] focus-visible:ring-[3px] focus-visible:ring-[var(--color-border-focus)]/50",
96
+ "aria-invalid:border-[var(--color-border-error)] aria-invalid:ring-[3px] aria-invalid:ring-[var(--color-border-error)]/20",
97
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
98
+ )}
99
+ />
100
+ ))}
101
+ </div>
102
+ )
103
+ }
104
+
105
+ export { InputOTP }
106
+ export type { InputOTPProps }
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export * from "./components/ui/filter-combobox"
27
27
  export * from "./components/ui/search-bar"
28
28
  export * from "./components/ui/input"
29
29
  export * from "./components/ui/input-group"
30
+ export * from "./components/ui/input-otp"
30
31
  export * from "./components/ui/kbd"
31
32
  export * from "./components/ui/label"
32
33
  export * from "./components/ui/pagination"
@@ -42,3 +43,7 @@ export * from "./components/ui/tab-count"
42
43
  export * from "./components/ui/tabs"
43
44
  export * from "./components/ui/textarea"
44
45
  export * from "./components/ui/tooltip"
46
+ export * from "./components/ui/flow-diagram"
47
+
48
+ // Brand textures + logo
49
+ export * from "./textures"
@@ -0,0 +1,60 @@
1
+ import * as React from "react"
2
+ import { cn } from "../lib/utils"
3
+ import { TEXTURES, type TextureName } from "./index"
4
+
5
+ /** The five sanctioned brand pairs. */
6
+ export type BrandPair =
7
+ | "yellow-darkforest"
8
+ | "rose-coral"
9
+ | "blue-navy"
10
+ | "beige-bronze"
11
+ | "gray-black"
12
+
13
+ export interface BrandTextureProps extends React.HTMLAttributes<HTMLDivElement> {
14
+ /** Which texture motif to render. */
15
+ name: TextureName
16
+ /** Which brand pair colors it. */
17
+ pair: BrandPair
18
+ /**
19
+ * Swap which member is ink vs background. By default the pair's dark member
20
+ * is the ink (shapes) and the light member is the background.
21
+ */
22
+ reverse?: boolean
23
+ /**
24
+ * How the motif fills the box. `cover` (default) crops to fill — right for
25
+ * edge-to-edge patterns; `contain` fits the whole motif without cropping —
26
+ * right for the logo or any motif that must stay intact.
27
+ */
28
+ fit?: "cover" | "contain"
29
+ }
30
+
31
+ /**
32
+ * Renders a brand texture colored by a sanctioned pair. Sets --texture-ink and
33
+ * --texture-bg on a wrapper; the inlined SVG reads them. The wrapper clips the
34
+ * texture, so size it via className (e.g. `h-40 w-full`).
35
+ */
36
+ function BrandTexture({ name, pair, reverse, fit = "cover", className, style, ...props }: BrandTextureProps) {
37
+ const Texture = TEXTURES[name]
38
+ const light = `var(--color-pair-${pair}-light)`
39
+ const dark = `var(--color-pair-${pair}-dark)`
40
+
41
+ const vars = {
42
+ "--texture-ink": reverse ? light : dark,
43
+ "--texture-bg": reverse ? dark : light,
44
+ backgroundColor: "var(--texture-bg)",
45
+ } as React.CSSProperties
46
+
47
+ const par = fit === "contain" ? "xMidYMid meet" : "xMidYMid slice"
48
+
49
+ return (
50
+ <div
51
+ className={cn("relative overflow-hidden", className)}
52
+ style={{ ...vars, ...style }}
53
+ {...props}
54
+ >
55
+ <Texture className="absolute inset-0 size-full" preserveAspectRatio={par} />
56
+ </div>
57
+ )
58
+ }
59
+
60
+ export { BrandTexture }