@kolkrabbi/kol-component 0.238.0 → 0.239.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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.239.1",
|
|
4
4
|
"description": "The core component library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"embla-carousel-react": "^8.6.0",
|
|
27
27
|
"pdfjs-dist": "^6.3.289",
|
|
28
28
|
"react-syntax-highlighter": "^16.1.1",
|
|
29
|
-
"@kolkrabbi/kol-markdown": "^0.1.
|
|
29
|
+
"@kolkrabbi/kol-markdown": "^0.1.3",
|
|
30
30
|
"@kolkrabbi/kol-search": "^0.3.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
@@ -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.
|
|
41
|
+
"@kolkrabbi/kol-icons": "^0.33.1"
|
|
42
42
|
},
|
|
43
43
|
"files": [
|
|
44
44
|
"src",
|
package/src/atoms/Button.jsx
CHANGED
|
@@ -12,7 +12,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
12
12
|
*
|
|
13
13
|
* @param {Object} props
|
|
14
14
|
* @param {ReactNode} props.children - Button content
|
|
15
|
-
* @param {'accent'|'danger'|'nav'} props.variant - The intent; the GROUND is `tone`. `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
|
|
15
|
+
* @param {'accent'|'danger'|'nav'|'tab'} props.variant - The intent; the GROUND is `tone`. `tab` is the tab chip (2026-10-02): quiet text at rest, a soft filled chip while `pressed`. `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
|
|
16
16
|
* @param {'xs'|'sm'|'md'|'lg'} props.size - Button size (default: 'md'); xs is the panel rung (ControlsXsRung, 2026-09-01) — kol-mono-8 in a 22px shell (20 icon-only), opt-in by prop
|
|
17
17
|
* @param {string} props.iconLeft - Icon name to display on the left
|
|
18
18
|
* @param {string} props.iconRight - Icon name to display on the right
|
|
@@ -93,7 +93,7 @@ const Button = ({
|
|
|
93
93
|
warned.add(variant)
|
|
94
94
|
console.warn(`Button: variant="${variant}" is deprecated — use tone="${VARIANT_TONE[variant]}"`)
|
|
95
95
|
}
|
|
96
|
-
const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'danger', 'grey']
|
|
96
|
+
const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'tab', 'danger', 'grey']
|
|
97
97
|
const known = !resolvedVariant || KNOWN.includes(resolvedVariant)
|
|
98
98
|
if (!known && import.meta.env.DEV) console.warn(`Button: unknown variant "${resolvedVariant}" — rendering the inherited tone (primary)`)
|
|
99
99
|
const variantClass = resolvedVariant && known ? `kol-btn-${resolvedVariant}` : ''
|
|
@@ -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
|
-
|
|
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: [['
|
|
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
|
|
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'} ${
|
|
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
|
-
{
|
|
356
|
+
{split && parts.length > 1
|
|
349
357
|
? parts.map((p, j) => (
|
|
350
|
-
|
|
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>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import Button from '../atoms/Button.jsx'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* TabChips — Tabs as a row of chips. the active tab is a filled chip, the rest are quiet text.
|
|
3
5
|
*
|
|
@@ -10,28 +12,37 @@
|
|
|
10
12
|
*
|
|
11
13
|
* Labels render as authored. Selection is the parent's.
|
|
12
14
|
*
|
|
15
|
+
* ON THE CONTROL SIZE RAMP (user 2026-10-02: *"can we make the tabtoggle align with the size ramp
|
|
16
|
+
* controls use?"*). A chip was a bare button at its own 24px, beside a 26px dropdown in the same
|
|
17
|
+
* bar. Each chip is a `Button variant="tab"` now, so `size` is the ramp's (22 · 26 · 32 · 40) and
|
|
18
|
+
* the chip's look is a Button state anything else can wear.
|
|
19
|
+
*
|
|
13
20
|
* @param {Array} tabs `[{ id, label }]`
|
|
14
21
|
* @param {string} value id of the active tab (controlled)
|
|
15
22
|
* @param {Function} onChange `(id) => void`
|
|
23
|
+
* @param {'xs'|'sm'|'md'|'lg'} [size='sm'] the control ramp's rung
|
|
16
24
|
* @param {string} [ariaLabel] accessible name for the strip
|
|
17
25
|
* @param {string} [className] extra classes on the row
|
|
18
26
|
*/
|
|
19
|
-
export default function TabChips({ tabs = [], value, onChange, ariaLabel, className = '' }) {
|
|
27
|
+
export default function TabChips({ tabs = [], value, onChange, size = 'sm', ariaLabel, className = '' }) {
|
|
20
28
|
return (
|
|
21
29
|
<div className={`flex items-center gap-1 ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
|
|
22
30
|
{tabs.map((t) => {
|
|
23
31
|
const active = t.id === value
|
|
24
32
|
return (
|
|
25
|
-
|
|
33
|
+
/* a tab is selected, not pressed — the role's own attribute replaces Button's aria-pressed */
|
|
34
|
+
<Button
|
|
26
35
|
key={t.id}
|
|
27
|
-
|
|
36
|
+
variant="tab"
|
|
37
|
+
size={size}
|
|
38
|
+
pressed={active}
|
|
28
39
|
role="tab"
|
|
29
40
|
aria-selected={active}
|
|
41
|
+
aria-pressed={undefined}
|
|
30
42
|
onClick={() => onChange?.(t.id)}
|
|
31
|
-
className={`kol-mono-12 rounded-[var(--kol-radius-sm)] px-3 py-1 transition-colors [@media(pointer:coarse)]:min-h-8 ${active ? 'bg-fg-08 text-emphasis' : 'text-meta hover:text-emphasis'}`}
|
|
32
43
|
>
|
|
33
44
|
{t.label}
|
|
34
|
-
</
|
|
45
|
+
</Button>
|
|
35
46
|
)
|
|
36
47
|
})}
|
|
37
48
|
</div>
|
|
@@ -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 (
|
|
244
|
+
if (liveFilters.size === 0) return result
|
|
233
245
|
return result.filter((item) => {
|
|
234
246
|
let matches = true
|
|
235
|
-
|
|
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,
|
|
259
|
+
}, [items, liveFilters, customFilterKeys, searchText, searchKeys])
|
|
248
260
|
|
|
249
|
-
const showCount = !showCountOnlyWhenFiltering || isExpanded || searchOpen ||
|
|
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
|
-
{
|
|
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">({
|
|
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 &&
|
|
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 ({
|
|
565
|
+
Clear all ({liveFilters.size})
|
|
554
566
|
</button>
|
|
555
567
|
)}
|
|
556
568
|
</div>
|
|
@@ -47,7 +47,10 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
47
47
|
* @param {ReactNode} media image / video / interactive node for the visual column
|
|
48
48
|
* @param {string} [ratio='4/5'] aspect ratio of the media frame. THE FRAME IS BOUNDED BY THE
|
|
49
49
|
* RUNG (SectionSplitMediaBounded, 2026-08-27): its height is the section's `height` rung minus
|
|
50
|
-
* the vertical padding, its width follows the ratio (capped at the column)
|
|
50
|
+
* the vertical padding, its width follows the ratio (capped at the column) — the RUNG is the
|
|
51
|
+
* dimension that holds and the WIDTH the one that yields, so a frame can stand narrower than its
|
|
52
|
+
* column; take a taller rung for bigger media. Below 901px, stacked, it is the other way round:
|
|
53
|
+
* the frame is as wide as the column and its height follows the ratio. Before, the frame
|
|
51
54
|
* was as wide as its column and set the section's height itself, so `40` · `60` · `80`
|
|
52
55
|
* rendered identically — a ladder that only floors is decorative for a card with media.
|
|
53
56
|
* @param {boolean} [mediaHover=false] zoom the media on frame hover (the CardFeatureItem numbers)
|
|
@@ -251,7 +254,15 @@ export default function SectionSplit({
|
|
|
251
254
|
* silently, which is why it was reported as a ProfileCard crop.
|
|
252
255
|
* Below 901 the frame is `w-full` + ratio: width decides, height
|
|
253
256
|
* follows, media never clips. ≥901 nothing moves. */
|
|
254
|
-
|
|
257
|
+
/* …AND THE WIDTH FOLLOWS AGAIN FROM 901 (section-split-frame-ratio-holds-at-desktop,
|
|
258
|
+
* kol-website 2026-10-02; user: *"why is this no longer its original ratio? its
|
|
259
|
+
* squished?"*). `w-full` was written for the stack and applied at every width, so
|
|
260
|
+
* beside the text the frame had BOTH dimensions fixed — the column's width and the
|
|
261
|
+
* rung's height — and `aspect-ratio` had nothing left to decide: a `5/4` frame
|
|
262
|
+
* rendered 2.22 at 1600 and cropped its image. From 901 the width is `auto` again:
|
|
263
|
+
* the rung sets the height, the ratio sets the width, the column caps it — the
|
|
264
|
+
* 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()}
|
|
255
266
|
style={{ aspectRatio: ratio }}
|
|
256
267
|
>
|
|
257
268
|
{media ?? (placeholder === true
|