@kolkrabbi/kol-component 0.39.0 → 0.41.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.39.0",
3
+ "version": "0.41.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",
@@ -1,3 +1,5 @@
1
+ import { useState } from 'react'
2
+
1
3
  const SIZE_MAP = {
2
4
  sm: 'w-8 h-8 kol-helper-12',
3
5
  md: 'w-10 h-10 kol-helper-14',
@@ -5,11 +7,41 @@ const SIZE_MAP = {
5
7
  xl: 'w-24 h-24 kol-helper-20',
6
8
  }
7
9
 
8
- export default function Avatar({ initial, size = 'sm', className = '' }) {
10
+ /**
11
+ * Avatar — the initials disc, or a photo at the same geometry.
12
+ *
13
+ * `src` was added when the ArticleHeader reconciliation (2026-08-15) found the
14
+ * consumer hand-rolling `<img className="w-12 h-12 rounded-full object-cover">`
15
+ * beside a grey-circle fallback, because the atom did initials only. The size
16
+ * ladder is the atom's to own — a caller writing its own w-/h- pair is how a
17
+ * fifth avatar size gets invented. Falls back to the initial on a broken src,
18
+ * so a dead photo URL degrades to the disc instead of a torn-image glyph.
19
+ *
20
+ * @param {string} initial glyph shown when there is no photo
21
+ * @param {string} [src] resolved image src — the consumer resolves it, this
22
+ * atom never builds a URL
23
+ * @param {string} [alt=''] photo alt text
24
+ * @param {'sm'|'md'|'lg'|'xl'} [size='sm']
25
+ */
26
+ export default function Avatar({ initial, src, alt = '', size = 'sm', className = '' }) {
9
27
  const sizeCls = SIZE_MAP[size] ?? SIZE_MAP.sm
28
+ const [failed, setFailed] = useState(false)
29
+ const shell = `kol-avatar rounded-full bg-surface-secondary shrink-0 ${sizeCls} ${className}`
30
+
31
+ if (src && !failed) {
32
+ return (
33
+ <img
34
+ src={src}
35
+ alt={alt}
36
+ className={`${shell} object-cover`}
37
+ onError={() => setFailed(true)}
38
+ />
39
+ )
40
+ }
41
+
10
42
  return (
11
43
  <span
12
- className={`kol-avatar inline-flex items-center justify-center rounded-full bg-surface-secondary text-emphasis font-narrow font-semibold shrink-0 ${sizeCls} ${className}`}
44
+ className={`${shell} inline-flex items-center justify-center text-emphasis font-narrow font-semibold`}
13
45
  >
14
46
  {initial}
15
47
  </span>
package/src/index.js CHANGED
@@ -100,6 +100,10 @@ export { default as GalleryCarousel } from './organisms/GalleryCarousel.jsx'
100
100
  export { default as AsciiCursor } from './utilities/AsciiCursor.jsx'
101
101
  export { default as BentoCard } from './molecules/BentoCard.jsx'
102
102
  export { default as Carousel } from './molecules/Carousel.jsx'
103
+ /* EmblaNav — THE prev/next pair. Exported so a consumer building its own embla
104
+ * stage reaches for it instead of re-typing the button markup, which is how the
105
+ * three in-package copies (and kol-website's CarouselNavigation) happened. */
106
+ export { default as EmblaNav } from './molecules/EmblaNav.jsx'
103
107
  export { default as ContentFilters } from './organisms/ContentFilters.jsx'
104
108
  export { default as CtaGlobal } from './organisms/CtaGlobal.jsx'
105
109
  export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
@@ -1,5 +1,6 @@
1
1
  import { Children, useCallback, useEffect, useState } from 'react'
2
2
  import useEmblaCarousel from 'embla-carousel-react'
3
+ import EmblaNav from './EmblaNav.jsx'
3
4
 
4
5
  export default function Carousel({ children, options = { align: 'start', loop: false, dragFree: true, containScroll: 'trimSnaps' }, className = '' }) {
5
6
  const [emblaRef, emblaApi] = useEmblaCarousel(options)
@@ -29,22 +30,12 @@ export default function Carousel({ children, options = { align: 'start', loop: f
29
30
  ))}
30
31
  </div>
31
32
  </div>
32
- <div className="kol-embla-controls">
33
- <button
34
- type="button"
35
- className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
36
- aria-label="Previous"
37
- onClick={() => emblaApi?.scrollPrev()}
38
- disabled={!canPrev}
39
- >‹</button>
40
- <button
41
- type="button"
42
- className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
43
- aria-label="Next"
44
- onClick={() => emblaApi?.scrollNext()}
45
- disabled={!canNext}
46
- >›</button>
47
- </div>
33
+ <EmblaNav
34
+ onPrev={() => emblaApi?.scrollPrev()}
35
+ onNext={() => emblaApi?.scrollNext()}
36
+ canPrev={canPrev}
37
+ canNext={canNext}
38
+ />
48
39
  </div>
49
40
  )
50
41
  }
@@ -0,0 +1,51 @@
1
+ import { Icon } from '@kolkrabbi/kol-icons'
2
+
3
+ /**
4
+ * EmblaNav — THE prev/next pair for an embla carousel.
5
+ *
6
+ * WHY IT EXISTS. Three components hand-wrote the same
7
+ * `.kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto` string with
8
+ * their own aria labels, and two of them used the literal text glyphs `‹` and
9
+ * `›` as the arrows — in a design system that ships a chevron icon set. The
10
+ * FeaturedCarousel reconciliation (2026-08-15) found the consumer's fork had
11
+ * built its own `CarouselNavigation` for precisely that reason: it wanted real
12
+ * icons. Rather than fold that file in beside three copies of the markup, the
13
+ * markup became one component. Same failure RailSection fixed for rails — a
14
+ * class is vocabulary, not grammar.
15
+ *
16
+ * MediaViewer keeps its own chips deliberately: they are absolutely positioned
17
+ * over an inverse-tier scrim, which is a different control, not this one.
18
+ *
19
+ * @param {Function} onPrev · @param {Function} onNext
20
+ * @param {boolean} [canPrev=true] · @param {boolean} [canNext=true] disabled state
21
+ * @param {number} [size=16] chevron size
22
+ * @param {'stack'|'inline'} [placement='stack'] `inline` drops the top gap and
23
+ * the end-alignment for a header row; `stack` sits under the viewport
24
+ * @param {string} [prevLabel='Previous'] · @param {string} [nextLabel='Next']
25
+ */
26
+ export default function EmblaNav({
27
+ onPrev,
28
+ onNext,
29
+ canPrev = true,
30
+ canNext = true,
31
+ size = 16,
32
+ placement = 'stack',
33
+ prevLabel = 'Previous',
34
+ nextLabel = 'Next',
35
+ className = '',
36
+ }) {
37
+ const btn = 'kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto'
38
+
39
+ return (
40
+ <div
41
+ className={`kol-embla-controls ${placement === 'inline' ? 'is-inline' : ''} ${className}`.trim()}
42
+ >
43
+ <button type="button" className={btn} aria-label={prevLabel} onClick={onPrev} disabled={!canPrev}>
44
+ <Icon name="chevron-left" size={size} />
45
+ </button>
46
+ <button type="button" className={btn} aria-label={nextLabel} onClick={onNext} disabled={!canNext}>
47
+ <Icon name="chevron-right" size={size} />
48
+ </button>
49
+ </div>
50
+ )
51
+ }
@@ -2,6 +2,7 @@ import { isValidElement, useCallback, useEffect, useState } from 'react'
2
2
  import useEmblaCarousel from 'embla-carousel-react'
3
3
  import Image from '../atoms/Image.jsx'
4
4
  import HlsVideo from '../atoms/HlsVideo.jsx'
5
+ import EmblaNav from '../molecules/EmblaNav.jsx'
5
6
  import OverlayGlassPanel from '../utilities/OverlayGlassPanel.jsx'
6
7
 
7
8
  /**
@@ -85,17 +86,42 @@ function SlideMedia({ media, onEnded, onTimeUpdate }) {
85
86
  * CTA is a plain anchor styled with the DS button classes plus an `onNavigate`
86
87
  * seam (router-agnostic — call `preventDefault` inside it for SPA nav).
87
88
  *
88
- * @param {Array} items slides: `{ media: { src, kind: 'image'|'video', poster, srcSet, alt }, title, description, href, ctaLabel, titleClassName }`
89
+ * RECONCILED 2026-08-15 against kol-website's fork (231L vs 259L, 458 diff
90
+ * lines). They were never a fork — two different engines. The fork ran
91
+ * framer-motion `AnimatePresence` over an index, which means **no drag at all**;
92
+ * this one runs embla, so the canon call went to the engine here, and with it
93
+ * the `{ media }` descriptor, OverlayGlassPanel, and the progress ring. Five
94
+ * capabilities crossed the other way — `children`, `fullWidth`, `rounded`, the
95
+ * `show*` visibility toggles and `subtitle` — plus the header nav placement.
96
+ * Deliberately NOT carried: the foundry title coupling (a size ramp keyed on
97
+ * the literal strings 'Málrómur'/'Tröllatunga' and a per-typeface inline
98
+ * `fontFamily`), `kol-label-mono-xs` (a deleted legacy family), and the hidden
99
+ * block that eagerly preloaded every slide image — embla plus `loading="eager"`
100
+ * on the visible slide covers that without fetching a whole gallery up front.
101
+ *
102
+ * @param {Array} items slides: `{ media: { src, kind: 'image'|'video', poster, srcSet, alt }, title, subtitle, description, href, ctaLabel, titleClassName, descriptionClassName, showTitle, showDescription, showCta }`
89
103
  * @param {string} sectionLabel header label (default 'Featured')
90
104
  * @param {string} ctaLabel default CTA copy; per-item `ctaLabel` overrides (default 'Learn more')
91
105
  * @param {string} height slide-frame height class (default 'h-[440px] md:h-[640px]')
92
106
  * @param {Function} renderTitle custom title renderer `(item) => ReactNode`; wins over the default
93
107
  * @param {string} titleClassName default title class; per-item `titleClassName` overrides
108
+ * @param {string} descriptionClassName default description class; per-item overrides
94
109
  * @param {boolean} showHeader show the label + `n / total` counter bar (default true)
110
+ * @param {boolean} showTitle global title visibility; per-item `showTitle` overrides (default true)
111
+ * @param {boolean} showDescription global description visibility; per-item overrides (default true)
112
+ * @param {boolean} showCta global CTA visibility; per-item `showCta` overrides (default true)
113
+ * @param {boolean} fullWidth drop the section's own vertical padding — the
114
+ * slide frame spans its container (default false)
115
+ * @param {boolean} rounded frame border + radius (default true)
116
+ * @param {'stack'|'header'} navPosition prev/next under the viewport, or in the
117
+ * header row beside the counter (default 'stack')
95
118
  * @param {boolean} autoPlay auto-advance: timer for image slides, `ended` for video slides (default false)
96
119
  * @param {number} autoPlayInterval image-slide timer in ms (default 5000)
97
120
  * @param {Function} onNavigate `(href, event) => void` CTA click seam for SPA routing
98
121
  * @param {Object} options embla options passthrough (default `{ align: 'center', loop: true }`)
122
+ * @param {node} children a STATIC overlay pinned over the stage — it does
123
+ * not travel with the slides (pointer-events pass
124
+ * through except on the child itself)
99
125
  * @param {string} className extra classes on the section
100
126
  */
101
127
  export default function FeaturedCarousel({
@@ -105,11 +131,19 @@ export default function FeaturedCarousel({
105
131
  height = 'h-[440px] md:h-[640px]',
106
132
  renderTitle,
107
133
  titleClassName = '',
134
+ descriptionClassName = '',
108
135
  showHeader = true,
136
+ showTitle = true,
137
+ showDescription = true,
138
+ showCta = true,
139
+ fullWidth = false,
140
+ rounded = true,
141
+ navPosition = 'stack',
109
142
  autoPlay = false,
110
143
  autoPlayInterval = 5000,
111
144
  onNavigate,
112
145
  options = { align: 'center', loop: true },
146
+ children,
113
147
  className = '',
114
148
  }) {
115
149
  const [emblaRef, emblaApi] = useEmblaCarousel(options)
@@ -175,17 +209,32 @@ export default function FeaturedCarousel({
175
209
 
176
210
  const showProgress = autoPlay && items.length > 1
177
211
 
212
+ const nav = (
213
+ <EmblaNav
214
+ onPrev={() => emblaApi?.scrollPrev()}
215
+ onNext={() => emblaApi?.scrollNext()}
216
+ canPrev={canPrev}
217
+ canNext={canNext}
218
+ placement={navPosition === 'header' ? 'inline' : 'stack'}
219
+ prevLabel="Previous slide"
220
+ nextLabel="Next slide"
221
+ />
222
+ )
223
+
178
224
  return (
179
225
  <section
180
- className={`kol-featured-carousel w-full ${className}`.trim()}
226
+ className={`kol-featured-carousel w-full ${fullWidth ? '' : 'py-16'} ${className}`.trim()}
181
227
  onMouseEnter={autoPlay ? () => setPaused(true) : undefined}
182
228
  onMouseLeave={autoPlay ? () => setPaused(false) : undefined}
183
229
  >
184
230
  {showHeader && (
185
- <div className="mb-6 flex items-center gap-4">
186
- {/* migrated off the deleted legacy kol-label-* family — label renders as authored */}
187
- <span className="kol-helper-12 text-auto">{sectionLabel}</span>
188
- <span className="kol-mono-12 text-fg-64">{selectedIndex + 1} / {items.length}</span>
231
+ <div className="mb-6 flex items-center justify-between gap-4">
232
+ <div className="flex items-center gap-4">
233
+ {/* migrated off the deleted legacy kol-label-* family — label renders as authored */}
234
+ <span className="kol-helper-12 text-auto">{sectionLabel}</span>
235
+ <span className="kol-mono-12 text-fg-64">{selectedIndex + 1} / {items.length}</span>
236
+ </div>
237
+ {navPosition === 'header' && nav}
189
238
  </div>
190
239
  )}
191
240
 
@@ -206,7 +255,9 @@ export default function FeaturedCarousel({
206
255
  const active = i === selectedIndex
207
256
  return (
208
257
  <div key={i} className="kol-embla-slide">
209
- <div className={`relative overflow-hidden rounded border border-fg-08 bg-surface-secondary ${height}`}>
258
+ <div
259
+ className={`relative overflow-hidden bg-surface-secondary ${rounded ? 'rounded border border-fg-08' : ''} ${height}`.replace(/\s+/g, ' ')}
260
+ >
210
261
  <SlideMedia
211
262
  media={item.media}
212
263
  onEnded={autoPlay && items.length > 1 && active ? advance : undefined}
@@ -214,11 +265,18 @@ export default function FeaturedCarousel({
214
265
  />
215
266
  <div className="relative z-10 flex h-full w-full items-center justify-center p-6">
216
267
  <OverlayGlassPanel maxWidth="max-w-[600px]">
217
- {renderTitleNode(item)}
218
- {item.description && (
219
- <p className="kol-mono-12 text-auto max-w-[600px]">{item.description}</p>
268
+ {(item.showTitle ?? showTitle) && renderTitleNode(item)}
269
+ {item.subtitle && (
270
+ <span className="kol-mono-10 text-fg-64">{item.subtitle}</span>
220
271
  )}
221
- {item.href && (
272
+ {(item.showDescription ?? showDescription) && item.description && (
273
+ <p
274
+ className={`kol-mono-12 text-auto max-w-[600px] ${item.descriptionClassName || descriptionClassName}`.trim()}
275
+ >
276
+ {item.description}
277
+ </p>
278
+ )}
279
+ {(item.showCta ?? showCta) && item.href && (
222
280
  <a
223
281
  href={item.href}
224
282
  onClick={onNavigate ? (e) => onNavigate(item.href, e) : undefined}
@@ -235,24 +293,18 @@ export default function FeaturedCarousel({
235
293
  })}
236
294
  </div>
237
295
  </div>
238
- </div>
239
296
 
240
- <div className="kol-embla-controls">
241
- <button
242
- type="button"
243
- className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
244
- aria-label="Previous"
245
- onClick={() => emblaApi?.scrollPrev()}
246
- disabled={!canPrev}
247
- >‹</button>
248
- <button
249
- type="button"
250
- className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
251
- aria-label="Next"
252
- onClick={() => emblaApi?.scrollNext()}
253
- disabled={!canNext}
254
- >›</button>
297
+ {/* Pinned over the stage, OUTSIDE the viewport — a caption or badge
298
+ * that must not travel with the slides. Clicks pass through except
299
+ * on the child itself. */}
300
+ {children && (
301
+ <div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
302
+ <div className="pointer-events-auto w-full">{children}</div>
303
+ </div>
304
+ )}
255
305
  </div>
306
+
307
+ {navPosition !== 'header' && nav}
256
308
  </div>
257
309
  </section>
258
310
  )