@kolkrabbi/kol-component 0.152.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.152.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
+ }
@@ -12,7 +12,7 @@ const FOCUSABLE =
12
12
 
13
13
  /**
14
14
  * ShellDrawer — THE edge drawer: a portalled panel that slides in from the
15
- * left or right viewport edge over a dimming backdrop. Distinct from Modal
15
+ * left, right or BOTTOM viewport edge over a dimming backdrop. Distinct from Modal
16
16
  * (centered prompt/confirm) and FullscreenOverlay (fills the whole viewport,
17
17
  * not an edge sheet). Escape, backdrop click and the built-in close button
18
18
  * all call `onClose`; body scroll locks while open; focus moves into the
@@ -26,8 +26,16 @@ const FOCUSABLE =
26
26
  *
27
27
  * @param {boolean} open drawer visible (drives slide in/out)
28
28
  * @param {Function} onClose close request (Esc / backdrop / close button)
29
- * @param {string} side 'left' | 'right' — edge the panel slides from
30
- * @param {number|string} width panel width (px number or CSS length); omit for full-width sheet
29
+ * @param {string} side 'left' | 'right' | 'bottom' — edge the panel slides from.
30
+ * `bottom` (ShellDrawerBottomSide, kol-mirror 2026-09-01) is the
31
+ * phone's sheet: full viewport width, slides up from +100% on Y,
32
+ * takes `height` where the sides take `width`, and pads its foot by
33
+ * `env(safe-area-inset-bottom)` so the last row clears the home bar.
34
+ * ONE DETENT — open or closed. A collapsed bar that grows on tap is
35
+ * a second height the consumer owns (mirror's 56px → 68dvh); this
36
+ * sheet does not carry it, and says so rather than half-build it.
37
+ * @param {number|string} width panel width (px number or CSS length); omit for full-width sheet — sides only
38
+ * @param {number|string} height panel height (px number or CSS length); omit for a content-sized sheet — `bottom` only
31
39
  * @param {ReactNode} header header-row content beside the close button (replaces the source's baked-in wordmark)
32
40
  * @param {boolean} backdrop render the dimming scrim (default true); false = panel alone, no darken/blur, close via × / Esc
33
41
  * @param {ReactNode} children scrollable panel body
@@ -38,6 +46,7 @@ export default function ShellDrawer({
38
46
  onClose,
39
47
  side = 'left',
40
48
  width,
49
+ height,
41
50
  header,
42
51
  closeSide = 'end',
43
52
  backdrop = true,
@@ -130,10 +139,18 @@ export default function ShellDrawer({
130
139
 
131
140
  if (!present || typeof document === 'undefined') return null
132
141
 
133
- const slideOut = side === 'right' ? 'translate-x-full' : '-translate-x-full'
142
+ const bottom = side === 'bottom'
143
+ const slideOut = bottom ? 'translate-y-full' : side === 'right' ? 'translate-x-full' : '-translate-x-full'
134
144
  const motionPanel = reduced
135
145
  ? ''
136
- : `transition-transform duration-200 ease-out ${shown ? 'translate-x-0' : slideOut}`
146
+ : `transition-transform duration-200 ease-out ${shown ? (bottom ? 'translate-y-0' : 'translate-x-0') : slideOut}`
147
+ /* the sheet's edge is the top; the sides' is the inner vertical */
148
+ const place = bottom
149
+ ? `inset-x-0 bottom-0 w-full max-h-full ${edge ? 'border-t' : ''}`
150
+ : `inset-y-0 max-w-full ${side === 'right' ? `right-0 ${edge ? 'border-l' : ''}` : `left-0 ${edge ? 'border-r' : ''}`} ${width == null ? 'w-full' : ''}`
151
+ const size = bottom
152
+ ? { ...(height != null ? { height: typeof height === 'number' ? `${height}px` : height } : {}), paddingBottom: 'calc(1rem + env(safe-area-inset-bottom, 0px))' }
153
+ : width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined
137
154
  const motionBackdrop = reduced
138
155
  ? ''
139
156
  : `transition-opacity duration-200 ease-out ${shown ? 'opacity-100' : 'opacity-0'}`
@@ -159,10 +176,8 @@ export default function ShellDrawer({
159
176
  tabIndex={-1}
160
177
  /* `edge` / `shadow` (SettingsPanelApproved, 2026-08-27): the approved settings
161
178
  * drawer has neither — the sheet meets the page flat */
162
- className={`fixed inset-y-0 z-[200] flex max-w-full flex-col bg-surface-primary px-4 py-4 outline-none md:px-5 lg:px-6 ${backdrop && shadow ? 'shadow-2xl' : ''} ${
163
- side === 'right' ? `right-0 ${edge ? 'border-l' : ''}` : `left-0 ${edge ? 'border-r' : ''}`
164
- } border-oq-08 ${width == null ? 'w-full' : ''} ${motionPanel} ${className}`}
165
- style={width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
179
+ className={`fixed z-[200] flex flex-col bg-surface-primary px-4 py-4 outline-none md:px-5 lg:px-6 ${backdrop && shadow ? 'shadow-2xl' : ''} ${place} border-oq-08 ${motionPanel} ${className}`}
180
+ style={size}
166
181
  >
167
182
  {/* closeSide="start": the reference sets the × glyph ~9px deeper than
168
183
  * the label column's edge, with extra top air (both reference frames