@kolkrabbi/kol-component 0.239.0 → 0.240.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.239.0",
3
+ "version": "0.240.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "react-dom": "^18.3.0 || ^19.0.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@kolkrabbi/kol-icons": "^0.33.0"
41
+ "@kolkrabbi/kol-icons": "^0.33.1"
42
42
  },
43
43
  "files": [
44
44
  "src",
@@ -69,7 +69,11 @@ const RAMP = {
69
69
  /* detail truncates (ShellHomeSystem, 2026-08-27): a wrapping blurb set three
70
70
  * cards' plates at three heights and misaligned the media */
71
71
  card: { title: 'kol-mono-14 text-emphasis', detail: 'kol-mono-10 text-meta truncate' },
72
- row: { title: 'kol-mono-12 text-emphasis', detail: 'kol-mono-10 text-meta' },
72
+ /* ONE LINE, BOTH (2026-10-03): the row is a fixed 36px rung and neither slot truncated, so a
73
+ * long detail took its full width and the title wrapped under it — "Slit-Scan Camera" on three
74
+ * lines in a row twice the height of its neighbours. The title keeps its width and the detail
75
+ * gives way (`lead` below). */
76
+ row: { title: 'kol-mono-12 text-emphasis truncate', detail: 'kol-mono-10 text-meta truncate' },
73
77
  },
74
78
  /* `print` folded into `catalog` 2026-08-29 — its row was already catalog's,
75
79
  * and its card differed only by `detail` not truncating. It aliases now. */
@@ -136,6 +140,9 @@ const RAMP = {
136
140
  * ['group', …] = 16px baseline group · ['between', …] = header.between ·
137
141
  * ['stack', …] = a vertical block on the tight 4px gap, nestable inside any of
138
142
  * the above. In a `between`, the LEADING part flexes and the trailing one hugs.
143
+ * ['lead', …] is a `between` the other way round: the leading part keeps its own
144
+ * width and the TRAILING one takes what is left and truncates — for a row whose
145
+ * trailing text is the long one (catalog's detail).
139
146
  *
140
147
  * A third kind, ['line', …] — fixed-width trailing columns for a table-like
141
148
  * row — was removed 2026-08-15. `default.row` was its only caller and moved to
@@ -148,7 +155,7 @@ const ORDER = {
148
155
  file: { card: ['title', ['group', 'date', 'size']], row: ['title', ['group', 'date', 'size']] },
149
156
  /* slide: title, then date · size · slide count on one baseline (read off olina's /slide-deck) */
150
157
  slide: { card: ['title', ['group', 'date', 'size', 'meta']], row: ['title', ['group', 'date', 'size', 'meta']] },
151
- catalog: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
158
+ catalog: { card: ['title', 'detail'], row: [['lead', 'title', 'detail']] },
152
159
  /* title + body are ONE block in BOTH forms — a `stack`, so they sit on the
153
160
  * tight 4px internal gap while tags, kicker and the meta group keep the
154
161
  * form's own outer gap. A flat column gave every line the same gap, which
@@ -318,7 +325,8 @@ export default function ContentText({
318
325
  const render = (entry, i, trailing = false) => {
319
326
  if (typeof entry === 'string') return line(entry)
320
327
  const [kind, ...slots] = entry
321
- const parts = slots.map((s, j) => render(s, j, kind === 'between' && j === slots.length - 1)).filter(Boolean)
328
+ const split = kind === 'between' || kind === 'lead'
329
+ const parts = slots.map((s, j) => render(s, j, split && j === slots.length - 1)).filter(Boolean)
322
330
  if (!parts.length) return null
323
331
  if (kind === 'stack') {
324
332
  /* A TRAILING column right-aligns (user ruling 2026-08-15) — it sits at
@@ -338,16 +346,21 @@ export default function ContentText({
338
346
  * baseline — a two-line column has no single baseline to share with the
339
347
  * title beside it, and baseline-aligning it hangs the second line below
340
348
  * the row's floor. */
341
- className={`flex min-w-0 ${parts.length > 1 && Array.isArray(slots[slots.length - 1]) ? 'items-center' : 'items-baseline'} ${kind === 'between' ? 'justify-between' : ''}`}
349
+ className={`flex min-w-0 ${parts.length > 1 && Array.isArray(slots[slots.length - 1]) ? 'items-center' : 'items-baseline'} ${split ? 'justify-between' : ''}`}
342
350
  style={{ gap: 'var(--kol-spacing-6)' }}
343
351
  >
344
352
  {/* the LEADING part of a `between` takes the room; the trailing column
345
353
  * hugs its content. Without this the left stack sized to its content
346
354
  * and the big line truncated at a quarter of the row's width while
347
355
  * empty space sat between the two columns. */}
348
- {kind === 'between' && parts.length > 1
356
+ {split && parts.length > 1
349
357
  ? parts.map((p, j) => (
350
- <div key={j} className={j === 0 ? 'min-w-0 flex-1' : 'shrink-0 text-right'}>{p}</div>
358
+ /* `lead`: the leading part is its own width (a flex item's default — it shrinks only
359
+ * when it alone is wider than the row); the trailing one starts from nothing and
360
+ * takes the remainder, so it is the one that truncates */
361
+ <div key={j} className={kind === 'lead'
362
+ ? (j === 0 ? 'min-w-0' : 'min-w-0 flex-1 text-right')
363
+ : (j === 0 ? 'min-w-0 flex-1' : 'shrink-0 text-right')}>{p}</div>
351
364
  ))
352
365
  : parts}
353
366
  </div>
@@ -681,8 +681,11 @@ export default function ColumnBrowser({
681
681
  const rootRef = useRef(null)
682
682
  /* `autoFocus` (ColumnBrowserSeams, kol-r2b2 2026-08-28): the arrow keys were dead until a row was
683
683
  * clicked — nothing focused the root on mount, and the consumer reached into the DOM for it. Re-run
684
- * on `prefix`, so a bucket switch from the header (which takes focus) hands the keyboard back. */
685
- useEffect(() => { if (autoFocus) rootRef.current?.focus() }, [autoFocus, prefix])
684
+ * on `prefix`, so a bucket switch from the header (which takes focus) hands the keyboard back.
685
+ * `preventScroll` (explorer-autofocus-scrolls-the-fixed-page, kol-website 2026-10-06): inside
686
+ * `PageShell mode="fixed"` the focus scrolled the page's scroller 102px to bring the browser into
687
+ * view, and the header opened off-screen. The keyboard still lands here; the page stays put. */
688
+ useEffect(() => { if (autoFocus) rootRef.current?.focus({ preventScroll: true }) }, [autoFocus, prefix])
686
689
 
687
690
  /* THE KEYBOARD SELECTS TOO (user 2026-09-23: *"the highlight focus is on the correct item, this
688
691
  * has been a problem for awhile"*). Clicks reported through `onSelectClick`; the arrows moved
@@ -226,13 +226,25 @@ const ContentFilters = ({
226
226
  onFilterChange?.(activeFilters, mode)
227
227
  }
228
228
 
229
+ /* A CHIP THAT IS NOT OFFERED DOES NOT FILTER (2026-10-03, kol-mirror's Library on the hub). The
230
+ * set outlives a change of `filterGroups` — a page with views swaps the groups under it — and a
231
+ * chip picked in one view kept filtering the next, where no item carries its key: an empty list
232
+ * under a "(1) filter active" whose chip is nowhere on the page. Only what the current groups
233
+ * offer applies (and the consumer's own `customFilterKeys`). The stored set is left alone, so
234
+ * the chip is still on when its view comes back. */
235
+ const liveFilters = useMemo(() => {
236
+ const offered = new Set(filterGroups.flatMap((g) => g.values.map((v) => `${g.key}:${v}`)))
237
+ const live = [...activeFilters].filter((f) => offered.has(f) || customFilterKeys.includes(f.split(':')[0]))
238
+ return live.length === activeFilters.size ? activeFilters : new Set(live)
239
+ }, [activeFilters, filterGroups, customFilterKeys])
240
+
229
241
  const filteredItems = useMemo(() => {
230
242
  /* the KOL engine, not a private substring match (apps review 2026-09-29) — utilities/searchItems */
231
243
  let result = filterItems(items, searchText, { keys: searchKeys })
232
- if (activeFilters.size === 0) return result
244
+ if (liveFilters.size === 0) return result
233
245
  return result.filter((item) => {
234
246
  let matches = true
235
- activeFilters.forEach((filter) => {
247
+ liveFilters.forEach((filter) => {
236
248
  const [filterType, value] = filter.split(':')
237
249
  if (customFilterKeys.includes(filterType)) return
238
250
  const itemValue = item[filterType]
@@ -244,9 +256,9 @@ const ContentFilters = ({
244
256
  })
245
257
  return matches
246
258
  })
247
- }, [items, activeFilters, customFilterKeys, searchText, searchKeys])
259
+ }, [items, liveFilters, customFilterKeys, searchText, searchKeys])
248
260
 
249
- const showCount = !showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0
261
+ const showCount = !showCountOnlyWhenFiltering || isExpanded || searchOpen || liveFilters.size > 0
250
262
 
251
263
  /* THE FILTER VALUE IS A TAG — the atom's whole reason to exist ("a Tag with
252
264
  * no handler is a Pill wearing the wrong name"). Three defects lived here
@@ -464,12 +476,12 @@ const ContentFilters = ({
464
476
  />
465
477
  {headerActions}
466
478
  </div>
467
- {activeFilters.size > 0 && (
479
+ {liveFilters.size > 0 && (
468
480
  <span
469
481
  className="kol-helper-12 text-fg-48 cursor-pointer select-none group flex items-center gap-2"
470
482
  onClick={(e) => { e.stopPropagation(); clearAllFilters() }}
471
483
  >
472
- <span className="underline">({activeFilters.size}) {activeFilters.size === 1 ? 'filter' : 'filters'} active</span>
484
+ <span className="underline">({liveFilters.size}) {liveFilters.size === 1 ? 'filter' : 'filters'} active</span>
473
485
  <span className="hidden group-hover:inline text-fg-64">×</span>
474
486
  </span>
475
487
  )}
@@ -543,14 +555,14 @@ const ContentFilters = ({
543
555
  <div className="flex min-w-0 flex-1 items-start gap-8 md:gap-16">
544
556
  {leadingActions}
545
557
  {isExpanded && filterGroups.map((group, i) => renderFilterGroup(group, i))}
546
- {isExpanded && activeFilters.size > 0 && (
558
+ {isExpanded && liveFilters.size > 0 && (
547
559
  <button
548
560
  type="button"
549
561
  onClick={clearAllFilters}
550
562
  className="kol-helper-12 transition-colors underline text-fg-32 hover:text-fg-48"
551
563
  style={{ background: 'transparent', border: 'none', cursor: 'pointer' }}
552
564
  >
553
- Clear all ({activeFilters.size})
565
+ Clear all ({liveFilters.size})
554
566
  </button>
555
567
  )}
556
568
  </div>
@@ -195,13 +195,18 @@ export default function SectionSplit({
195
195
  <section
196
196
  ref={themeRef}
197
197
  data-theme={themeStamp}
198
- className={`kol-section-split flex flex-col justify-center px-5 py-16 md:px-8 md:py-24 lg:px-14 lg:py-32 [--kol-section-py:4rem] md:[--kol-section-py:6rem] lg:[--kol-section-py:8rem] ${minHeightClass(height)} ${surfaceClass(background, 'none')} ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
198
+ className={`kol-section-split flex flex-col justify-center py-16 md:py-24 lg:py-32 [--kol-section-py:4rem] md:[--kol-section-py:6rem] lg:[--kol-section-py:8rem] ${minHeightClass(height)} ${surfaceClass(background, 'none')} ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
199
199
  style={sectionStyle}
200
200
  >
201
201
  {/* ONE cap for the whole section family (user ruling 2026-08-26): the shell's
202
202
  * --kol-container-max ladder — 100% → 1400 → 1600 → 1800. Split ran 1200,
203
203
  * cards 1400, the CTA 1600: three numbers for one job, unflagged. */}
204
- <div className={`w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto ${grid} ${innerClassName}`.replace(/\s+/g, ' ').trim()}>
204
+ {/* …AND THE RULED GUTTER IS INSIDE THAT CAP (section-split-content-media-and-ruled-gutter,
205
+ * kol-website 2026-10-06): the section padded itself px-5/8/14 OUTSIDE the cap, so its text
206
+ * started 56 at 1440 against 48 for every `.kol-page` band below it, and at 1920 the padding
207
+ * fell outside the cap and did nothing. Now the inner box is `.kol-page`'s own measure —
208
+ * the cap, then `--kol-pad-section-x` inside it. `fill` keeps its edge-to-edge halves. */}
209
+ <div className={`w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto px-[var(--kol-pad-section-x,20px)] ${grid} ${innerClassName}`.replace(/\s+/g, ' ').trim()}>
205
210
  {/* `order` rather than `flex-row-reverse`: the grid is one column below
206
211
  * 901px, and DOM order is what decides the stack there. */}
207
212
  <SectionText
@@ -262,8 +267,13 @@ export default function SectionSplit({
262
267
  * rendered 2.22 at 1600 and cropped its image. From 901 the width is `auto` again:
263
268
  * the rung sets the height, the ratio sets the width, the column caps it — the
264
269
  * 2026-08-27 rule written on the `ratio` prop. */
265
- className={`kol-section-split-visual relative w-full min-[901px]:w-auto max-w-full justify-self-center rounded-[var(--kol-radius-sm)] min-[901px]:h-[calc(var(--kol-section-h,60vh)_-_2*var(--kol-section-py,4rem))] ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
266
- style={{ aspectRatio: ratio }}
270
+ /* `ratio="auto"` IS CONTENT, NOT A FRAME (section-split-content-media-and-ruled-gutter,
271
+ * kol-website 2026-10-06): with no ratio there is nothing for the width to follow, so
272
+ * the rung-minus-padding height made a fixed window over a card with its own height —
273
+ * /studio's 621px ProfileCard showed as a 104px strip at 1440. Auto media takes the
274
+ * column's width and its own height, at every width; a ratio keeps the bounded rule. */
275
+ className={`kol-section-split-visual relative w-full max-w-full justify-self-center rounded-[var(--kol-radius-sm)] ${ratio === 'auto' ? '' : 'min-[901px]:w-auto min-[901px]:h-[calc(var(--kol-section-h,60vh)_-_2*var(--kol-section-py,4rem))]'} ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
276
+ style={ratio === 'auto' ? undefined : { aspectRatio: ratio }}
267
277
  >
268
278
  {media ?? (placeholder === true
269
279
  ? <AssetPlaceholder className="h-full w-full" />
@@ -121,7 +121,10 @@ export function usePopover({
121
121
 
122
122
  const interactions = useInteractions([
123
123
  useClick(data.context, { enabled: click }),
124
- useHover(data.context, { enabled: hover, delay: hoverDelay, move: false }),
124
+ /* `mouseOnly` — a hover is a mouse's (2026-10-05, found on labs' phone sheet). Touch has none:
125
+ * a tap read as a hover that never ends, so a tooltip opened on the tap and stayed over the
126
+ * next control. Only Tooltip opens on hover, and the keyboard still reaches it through `focus`. */
127
+ useHover(data.context, { enabled: hover, delay: hoverDelay, move: false, mouseOnly: true }),
125
128
  useFocus(data.context, { enabled: focus }),
126
129
  /* `dismiss` may be an object of floating-ui's own options — `{ outsidePress: false }` keeps Esc
127
130
  * and drops the outside click (Dropdown `stayOpen`) */