@kolkrabbi/kol-component 0.153.0 → 0.154.0

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.153.0",
3
+ "version": "0.154.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -148,6 +148,7 @@ export { default as PlaybackBar } from './molecules/PlaybackBar.jsx'
148
148
  export { default as AudioSheet } from './molecules/AudioSheet.jsx'
149
149
  export { default as DocPage } from './molecules/DocPage.jsx'
150
150
  export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
151
+ export { default as ProfileCard } from './molecules/ProfileCard.jsx'
151
152
  export { parseFrontmatter } from './utilities/frontmatter.js'
152
153
  export { readCover } from './utilities/id3.js'
153
154
  export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegment, groupSegments, groupVariants, posterFor, partition } from './utilities/mediaKinds.js'
@@ -0,0 +1,204 @@
1
+ import { useId, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+ import IconFrame from '../atoms/IconFrame.jsx'
4
+ import { Tooltip } from '../utilities/Popover.jsx'
5
+
6
+ /* taxonomy-ok: nests kol-icons's Icon (a package import the relative-import
7
+ * check can't see). */
8
+
9
+ /**
10
+ * ProfileCard — the digital namecard (ProfileCard, kol-website 2026-09-01;
11
+ * carried class-for-class from `apps/web/src/components/ui/ProfileCard.jsx`):
12
+ * a square photo with a disclosure control inset on it, and a SHELF that opens
13
+ * under it (vertical) or beside it (horizontal) — logo, name, mailto, a rack of
14
+ * socials. The shelf is `bg-surface-inverse` / `text-auto-inverse` at every
15
+ * theme: the inverse of the page is the design, not a bug.
16
+ *
17
+ * THE SHELF SIZES TO ITS CONTENT. The source held it in a fixed box
18
+ * (`h-60`/`h-44`/`h-32`/`h-24`, `overflow-hidden`) and every vertical size
19
+ * clipped its own socials rack on a phone — lg needed 204px inside 176, sm 140
20
+ * inside 96. The consumer patched a `min-h` floor under a raised `max-height`
21
+ * ceiling; that is two guesses at one number. Here the open state animates
22
+ * `grid-template-rows: 0fr → 1fr` — the browser measures, nothing is guessed,
23
+ * and a sixth social or a longer name never clips. Horizontal keeps the user's
24
+ * 2026-08-27 ruling — the CARD holds a fixed square and the PHOTO crops as the
25
+ * shelf comes in — as `grid-template-columns: 0px → 224px`, ONE number where
26
+ * the source carried three (`w-56`, `'320px'`, an inline `'224px'` — only the
27
+ * last was ever read).
28
+ *
29
+ * SCALE AND WIDTH ARE SEPARATE. `size` drives the ramp — logo height, mono
30
+ * step, glyph size, rack gap, control inset — and caps the width
31
+ * (`max-w-[680/480/320/200]`); the card is `w-full` inside that cap, so a
32
+ * caller's `className="w-full"` no longer fights a second width utility.
33
+ *
34
+ * THE DISCLOSURE IS A BUTTON, NOT A SWITCH. The source opened the shelf with
35
+ * `ToggleSwitch` — a form control that states a persistent setting, used for a
36
+ * reveal that sets nothing. Ruling applied here rather than minted as a second
37
+ * component: a disclosure is a button carrying `aria-expanded` +
38
+ * `aria-controls`, `plus` closed / `minus` open. Over MEDIA it is
39
+ * `IconFrame variant="secondary" radius="full"` — the glyph needs a surface
40
+ * (the bare `nav` glyph was measured invisible over the photo: page ink on a
41
+ * dark picture); on a surface the bare `nav` idiom the close X standardised
42
+ * on (FullscreenOverlayCloseIdiom) is the same ruling with no frame.
43
+ *
44
+ * Brand content is the CONSUMER'S: `logo` is a slot (the source baked
45
+ * `kol-brand`'s lockup, which this package cannot import), `name` / `email` /
46
+ * `socials` are props with no defaults.
47
+ *
48
+ * @param {string} image photo URL — square, `object-cover object-top`
49
+ * @param {string} alt the photo's alt (default: `name`)
50
+ * @param {ReactNode} logo the lockup — sized by the ramp (`[&>svg]:h-full`), `text-auto-inverse`
51
+ * @param {string} name the person
52
+ * @param {string} email rendered as a `mailto:`
53
+ * @param {Array} socials `[{ icon, href, label }]` — one Icon-in-Tooltip each, external links
54
+ * @param {'xl'|'lg'|'md'|'sm'} size the ramp + width cap (680 · 480 · 320 · 200); default xl
55
+ * @param {'vertical'|'horizontal'} orientation shelf under the photo (card grows) or beside it (card holds its square, photo crops)
56
+ * @param {string} variant alias for the source's names — `'lg-h'` = `size="lg" orientation="horizontal"`, any other value = `size`
57
+ * @param {boolean} open controlled open state
58
+ * @param {boolean} defaultOpen uncontrolled initial state (default false)
59
+ * @param {Function} onOpenChange `(open) => void`
60
+ * @param {string} className extra classes on the card
61
+ */
62
+
63
+ const SIZES = {
64
+ xl: { cap: 'max-w-[680px]', pad: 'p-6', logo: 'h-20', text: 'kol-mono-16', iconSize: 24, iconBox: 'w-8 h-8', iconGap: 'gap-2', control: 'bottom-4 left-4' },
65
+ lg: { cap: 'max-w-[480px]', pad: 'p-5', logo: 'h-14', text: 'kol-mono-14', iconSize: 20, iconBox: 'w-7 h-7', iconGap: 'gap-1.5', control: 'bottom-3 left-3' },
66
+ md: { cap: 'max-w-[320px]', pad: 'p-4', logo: 'h-10', text: 'kol-mono-12', iconSize: 16, iconBox: 'w-6 h-6', iconGap: 'gap-1', control: 'bottom-3 left-3' },
67
+ sm: { cap: 'max-w-[200px]', pad: 'p-3', logo: 'h-7', text: 'kol-mono-10', iconSize: 12, iconBox: 'w-5 h-5', iconGap: 'gap-1', control: 'bottom-2 left-2' },
68
+ }
69
+ /* the source's `lg-h`: lg's ramp with a 16px glyph in a 24 box, p-4, and the
70
+ * one open-shelf width */
71
+ const HORIZONTAL = { pad: 'p-4', iconSize: 16, iconBox: 'w-6 h-6', shelf: 224 }
72
+ const SHADOW = '0 112px 192px -80px rgba(0,0,0,0.6)'
73
+
74
+ export default function ProfileCard({
75
+ image,
76
+ alt,
77
+ logo,
78
+ name,
79
+ email,
80
+ socials = [],
81
+ size = 'xl',
82
+ orientation = 'vertical',
83
+ variant,
84
+ open,
85
+ defaultOpen = false,
86
+ onOpenChange,
87
+ className = '',
88
+ }) {
89
+ /* the alias maps and gains nothing (gate R4) */
90
+ const horizontal = variant === 'lg-h' || (variant == null && orientation === 'horizontal')
91
+ const s = SIZES[variant === 'lg-h' ? 'lg' : (variant ?? size)] ?? SIZES.xl
92
+ const ramp = horizontal ? { ...s, ...HORIZONTAL } : s
93
+
94
+ const [localOpen, setLocalOpen] = useState(defaultOpen)
95
+ const isOpen = open ?? localOpen
96
+ const setOpen = (next) => {
97
+ if (open == null) setLocalOpen(next)
98
+ onOpenChange?.(next)
99
+ }
100
+ const shelfId = useId()
101
+
102
+ const control = (
103
+ <div className={`absolute ${ramp.control}`}>
104
+ <IconFrame
105
+ name={isOpen ? 'minus' : 'plus'}
106
+ variant="secondary"
107
+ size="md"
108
+ radius="full"
109
+ aria-expanded={isOpen}
110
+ aria-controls={shelfId}
111
+ aria-label={isOpen ? 'Hide contact details' : 'Show contact details'}
112
+ onClick={() => setOpen(!isOpen)}
113
+ />
114
+ </div>
115
+ )
116
+ const photo = (
117
+ <img src={image} alt={alt ?? name ?? ''} className="w-full h-full object-cover object-top" loading="lazy" />
118
+ )
119
+ const lockup = logo != null && (
120
+ <div className={`inline-flex ${ramp.logo} text-auto-inverse [&>svg]:h-full [&>svg]:w-auto`}>{logo}</div>
121
+ )
122
+ const contact = (
123
+ <div className={`flex flex-col ${horizontal ? 'gap-1.5' : 'gap-1'} antialiased`} style={{ lineHeight: 1 }}>
124
+ {name && <span className={`${ramp.text} text-auto-inverse`}>{name}</span>}
125
+ {email && (
126
+ <a
127
+ href={`mailto:${email}`}
128
+ className={`${ramp.text} text-auto-inverse underline decoration-2 decoration-transparent hover:decoration-current transition-all`}
129
+ >
130
+ {email}
131
+ </a>
132
+ )}
133
+ </div>
134
+ )
135
+ const rack = (
136
+ <div className={`flex ${horizontal ? 'flex-row' : 'flex-col items-start'} ${ramp.iconGap}`}>
137
+ {socials.map(({ icon, href, label }) => (
138
+ <Tooltip key={icon} label={label}>
139
+ <a
140
+ href={href}
141
+ target="_blank"
142
+ rel="noopener noreferrer"
143
+ aria-label={label}
144
+ className={`${ramp.iconBox} flex items-center justify-center text-auto-inverse transition-transform hover:scale-125`}
145
+ >
146
+ <Icon name={icon} size={ramp.iconSize} />
147
+ </a>
148
+ </Tooltip>
149
+ ))}
150
+ </div>
151
+ )
152
+
153
+ if (horizontal) {
154
+ return (
155
+ <div
156
+ className={`grid w-full aspect-square rounded overflow-hidden transition-[grid-template-columns] duration-300 ease-in-out ${className}`}
157
+ style={{ gridTemplateColumns: `${isOpen ? HORIZONTAL.shelf : 0}px minmax(0, 1fr)`, boxShadow: SHADOW }}
158
+ >
159
+ {/* Shelf — LEFT. The inner column holds the open width so the text does
160
+ * not reflow while the track animates. */}
161
+ <div id={shelfId} className="min-w-0 overflow-hidden bg-surface-inverse">
162
+ <div className={`h-full ${ramp.pad} flex flex-col justify-between items-start`} style={{ width: HORIZONTAL.shelf }}>
163
+ {lockup}
164
+ <div className="flex flex-col gap-8">
165
+ {contact}
166
+ {rack}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ {/* Photo — RIGHT: crops as the shelf comes in, the card never moves */}
171
+ <div className="relative min-w-0 h-full overflow-hidden bg-surface-secondary">
172
+ {photo}
173
+ {control}
174
+ </div>
175
+ </div>
176
+ )
177
+ }
178
+
179
+ return (
180
+ <div className={`w-full ${ramp.cap} rounded overflow-hidden ${className}`} style={{ boxShadow: SHADOW }}>
181
+ {/* Photo — square */}
182
+ <div className="relative w-full aspect-square overflow-hidden bg-surface-secondary">
183
+ {photo}
184
+ {control}
185
+ </div>
186
+ {/* Shelf — content-sized: the row track animates 0fr → 1fr and the browser
187
+ * measures; `min-h-0 overflow-hidden` on the item is what lets 0fr close */}
188
+ <div
189
+ className="grid transition-[grid-template-rows] duration-300 ease-in-out"
190
+ style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}
191
+ >
192
+ <div id={shelfId} className="min-h-0 overflow-hidden bg-surface-inverse">
193
+ <div className={`${ramp.pad} flex justify-between items-end`}>
194
+ <div className="self-stretch flex flex-col justify-between items-start">
195
+ {lockup}
196
+ {contact}
197
+ </div>
198
+ {rack}
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ )
204
+ }