@kolkrabbi/kol-component 0.45.0 → 0.47.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.
@@ -24,6 +24,21 @@ import IconFrame from '../atoms/IconFrame.jsx'
24
24
  * @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
25
25
  * @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
26
26
  * @param {ElementType} props.iconComponent — icon seam (defaults to DS Icon; needs `filter` + `search`)
27
+ *
28
+ * Look seams — all default to the shipped values, pass nothing and nothing changes:
29
+ * @param {string} props.titleClassName — header title type/ink
30
+ * @param {boolean} props.titleUppercase — cases the title (default false)
31
+ * @param {string} props.labelClassName — filter-group label type/ink
32
+ * @param {boolean} props.labelUppercase — cases the group label (default true)
33
+ * @param {string} props.tagVariant — filter chip variant ('primary' grey fill)
34
+ * @param {string} props.tagSize — filter chip size
35
+ * @param {string} props.tagActiveClassName — chip ink, selected
36
+ * @param {string} props.tagRestClassName — chip ink, unselected
37
+ * @param {string} props.viewClassName — RECENT/SAVED strip type
38
+ * @param {string} props.layoutClassName — LIST/GRID strip type
39
+ * @param {string} props.stripActiveClassName — strip ink, selected (both strips)
40
+ * @param {string} props.stripRestClassName — strip ink, unselected (both strips)
41
+ * @param {string} props.countClassName — the "N of N" type/ink
27
42
  */
28
43
  const ContentFilters = ({
29
44
  items,
@@ -46,6 +61,22 @@ const ContentFilters = ({
46
61
  showCountOnlyWhenFiltering = false,
47
62
  iconComponent,
48
63
  className = '',
64
+ /* THE LOOK SEAMS. Every one defaults to what the component shipped, so
65
+ * passing nothing renders exactly as before — these exist because the
66
+ * hardcoded values were the wrong call for every consumer but one. */
67
+ titleClassName = 'kol-helper-14',
68
+ titleUppercase = false,
69
+ labelClassName = 'kol-helper-12 text-fg-96',
70
+ labelUppercase = true,
71
+ tagVariant = 'primary',
72
+ tagSize = 'sm',
73
+ tagActiveClassName = 'text-fg-96',
74
+ tagRestClassName = 'text-fg-48',
75
+ viewClassName = 'kol-helper-14',
76
+ layoutClassName = 'kol-helper-12',
77
+ stripActiveClassName = 'text-fg-96',
78
+ stripRestClassName = 'text-fg-32 hover:text-fg-48',
79
+ countClassName = 'kol-helper-12 text-fg-64',
49
80
  }) => {
50
81
  /* Icon seam — consumers on a local icon shelf pass their own component
51
82
  * rather than being forced onto the DS set. Needs `filter` + `search`. */
@@ -119,13 +150,18 @@ const ContentFilters = ({
119
150
  })
120
151
  }, [items, activeFilters, customFilterKeys, searchText, searchKeys])
121
152
 
153
+ const showCount = !showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0
154
+
122
155
  /* THE FILTER VALUE IS A TAG — the atom's whole reason to exist ("a Tag with
123
156
  * no handler is a Pill wearing the wrong name"). Three defects lived here
124
157
  * until 2026-08-15, all of them working around the atom instead of using it:
125
158
  *
126
159
  * variant="default" — not a declared variant; `VARIANTS[v] ?? primary`
127
- * silently rendered the FILLED chip. The outlined
128
- * chip these want is `secondary`.
160
+ * silently rendered the FILLED chip which is what
161
+ * the chip should be. `primary` is now declared, not
162
+ * fallen through to. (An outlined `secondary` shipped
163
+ * 2026-08-15 and was ruled wrong the same day: the
164
+ * grey filled chip is the look.)
129
165
  * className border-* — hand-rolled active state beside the atom's own
130
166
  * `active` prop, which is what drives `.is-active`.
131
167
  * <div onClick> — the handler on a wrapper, so Tag rendered a <span>
@@ -147,19 +183,19 @@ const ContentFilters = ({
147
183
  * never what anyone looked at. No inline letter-spacing: the helper ramp
148
184
  * carries its own, and the override the fork added was not in the
149
185
  * rendered path either. */}
150
- <h4 className="kol-helper-12 text-fg-96" style={{ textTransform: 'uppercase' }}>
186
+ <h4 className={labelClassName} style={labelUppercase ? { textTransform: 'uppercase' } : undefined}>
151
187
  {group.label}
152
188
  </h4>
153
189
  <div className="flex flex-wrap gap-4">
154
190
  {group.values.map((value) => (
155
191
  <Tag
156
192
  key={value}
157
- size="sm"
158
- variant="secondary"
193
+ size={tagSize}
194
+ variant={tagVariant}
159
195
  hash={false}
160
196
  active={activeFilters.has(`${group.key}:${value}`)}
161
197
  onClick={() => toggleFilter(group.key, value)}
162
- className={activeFilters.has(`${group.key}:${value}`) ? 'text-fg-96' : 'text-fg-48'}
198
+ className={activeFilters.has(`${group.key}:${value}`) ? tagActiveClassName : tagRestClassName}
163
199
  >
164
200
  {value}
165
201
  </Tag>
@@ -181,7 +217,7 @@ const ContentFilters = ({
181
217
  * states"); the span here was the same defect that promoted it. */}
182
218
  <h2 className="flex items-center gap-2">
183
219
  {titleIcon && <IconFrame name={titleIcon} variant="secondary" size="md" />}
184
- <span className="kol-helper-16">{title}</span>
220
+ <span className={titleClassName} style={titleUppercase ? { textTransform: 'uppercase' } : undefined}>{title}</span>
185
221
  </h2>
186
222
  <Divider variant="vertical" className="self-stretch py-1" />
187
223
  <div className="flex items-center gap-1">
@@ -201,7 +237,7 @@ const ContentFilters = ({
201
237
  width: searchOpen ? 200 : 32,
202
238
  background: searchOpen ? 'var(--kol-surface-secondary)' : 'transparent',
203
239
  border: 'none',
204
- transition: 'width 600ms cubic-bezier(0.16, 1, 0.3, 1), background 400ms cubic-bezier(0.16, 1, 0.3, 1)',
240
+ transition: 'width 600ms var(--kol-ease-house), background 400ms var(--kol-ease-house)',
205
241
  overflow: 'hidden',
206
242
  }}
207
243
  onClick={() => {
@@ -216,7 +252,7 @@ const ContentFilters = ({
216
252
  className="flex items-center justify-center flex-shrink-0"
217
253
  style={{
218
254
  opacity: searchOpen ? 0 : 1,
219
- transition: 'opacity 300ms cubic-bezier(0.16, 1, 0.3, 1)',
255
+ transition: 'opacity 300ms var(--kol-ease-house)',
220
256
  position: searchOpen ? 'absolute' : 'relative',
221
257
  }}
222
258
  >
@@ -250,11 +286,6 @@ const ContentFilters = ({
250
286
  </div>
251
287
 
252
288
  <div className="flex items-center gap-8">
253
- {(!showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0) && (
254
- <span className="kol-helper-14 text-fg-64">
255
- {filteredItems.length} of {totalCount}
256
- </span>
257
- )}
258
289
  {/* RECENT / SAVED is the SAME STRIP as LIST / GRID, not a ViewToggle.
259
290
  * Read off kol-monitor's original (_tmp/2026-08-15-shell-adoption/
260
291
  * ContentFilters.jsx:225-232): inline spans, `kol-helper-14`,
@@ -270,7 +301,7 @@ const ContentFilters = ({
270
301
  <span
271
302
  key={opt.value}
272
303
  onClick={() => handleViewModeChange(opt.value)}
273
- className={`kol-helper-14 cursor-pointer select-none ${viewMode === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
304
+ className={`${viewClassName} cursor-pointer select-none ${viewMode === opt.value ? stripActiveClassName : stripRestClassName}`}
274
305
  style={{ textTransform: 'uppercase', letterSpacing: 1 }}
275
306
  >
276
307
  {opt.label}
@@ -292,7 +323,7 @@ const ContentFilters = ({
292
323
  * the values hang beneath it. The strip previously rendered in its own
293
324
  * row AFTER this block, which is why expanding a filter group pushed it
294
325
  * down the page — the defect that started the whole ticket. */}
295
- {(layoutOptions || isExpanded) && (
326
+ {(layoutOptions || isExpanded || showCount) && (
296
327
  <div className="flex items-start justify-between gap-16 pb-4">
297
328
  <div className="flex items-start gap-16">
298
329
  {isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
@@ -307,20 +338,31 @@ const ContentFilters = ({
307
338
  </button>
308
339
  )}
309
340
  </div>
310
- {layoutOptions && (
311
- <div className="flex items-center gap-4 flex-shrink-0">
312
- {layoutOptions.map((opt) => (
313
- <span
314
- key={opt.value}
315
- onClick={() => setLayout(opt.value)}
316
- className={`kol-helper-12 cursor-pointer select-none ${layout === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
317
- style={{ letterSpacing: 1 }}
318
- >
319
- {opt.label}
320
- </span>
321
- ))}
322
- </div>
323
- )}
341
+ {/* The count rides the LAYOUT row, not the header (user ruling
342
+ * 2026-08-15) — same `kol-helper-12` + 1px tracking as LIST/GRID
343
+ * beside it. Ink stays `text-fg-64`: it is static information, not
344
+ * a toggle, so it takes neither the 96 active nor the 32 rest. */}
345
+ <div className="flex items-center gap-4 flex-shrink-0">
346
+ {showCount && (
347
+ <span className={countClassName} style={{ letterSpacing: 1 }}>
348
+ {filteredItems.length} of {totalCount}
349
+ </span>
350
+ )}
351
+ {layoutOptions && (
352
+ <div className="flex items-center gap-4">
353
+ {layoutOptions.map((opt) => (
354
+ <span
355
+ key={opt.value}
356
+ onClick={() => setLayout(opt.value)}
357
+ className={`${layoutClassName} cursor-pointer select-none ${layout === opt.value ? stripActiveClassName : stripRestClassName}`}
358
+ style={{ letterSpacing: 1 }}
359
+ >
360
+ {opt.label}
361
+ </span>
362
+ ))}
363
+ </div>
364
+ )}
365
+ </div>
324
366
  </div>
325
367
  )}
326
368
 
@@ -6,17 +6,22 @@
6
6
  * are visibly flagged rather than showing the browser's broken-image icon.
7
7
  */
8
8
 
9
+ /* `radius` (2026-08-15 user ruling): a placeholder inside a host that already
10
+ * CLIPS its own corners rounds twice — the visible double-round on the card
11
+ * form. The host turns it off; it stays on everywhere else, so nothing that
12
+ * renders a bare placeholder changes. */
9
13
  export default function AssetPlaceholder({
10
14
  category,
11
15
  name,
12
16
  aspectRatio = '16 / 9',
13
17
  note = 'MISSING',
18
+ radius = true,
14
19
  className = '',
15
20
  }) {
16
21
  const label = [category, name].filter(Boolean).join(' · ')
17
22
  return (
18
23
  <div
19
- className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] rounded-[var(--kol-radius-sm)] bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
24
+ className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] ${radius ? 'rounded-[var(--kol-radius-sm)]' : ''} bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
20
25
  style={{ aspectRatio }}
21
26
  role="img"
22
27
  aria-label={`${label || 'asset'} — ${note}`}
@@ -1,42 +0,0 @@
1
- import { useState } from 'react'
2
- import { Icon } from '@kolkrabbi/kol-icons'
3
-
4
- /**
5
- * CopyButton — THE copy-to-clipboard control (2026-08-09 user ruling): the
6
- * 32×32 icon button — `copy` glyph flipping to `check` for 2s on copied,
7
- * no text label. This is the button CodeBlock carried privately since the
8
- * 2026-07-28 elder replication, promoted to the one shared atom; the old
9
- * Copy/Copied label chip (one-off SVGs outside the icon set) is retired.
10
- * Chrome comes from .kol-copy-btn (kol-theme); parents add their own
11
- * positioning class (e.g. CodeBlock's .kol-codeblock-copy).
12
- *
13
- * Props:
14
- * text — string (or () => string) written to the clipboard
15
- * className — extra classes (positioning etc.)
16
- */
17
- export default function CopyButton({ text, className = '', ...props }) {
18
- const [copied, setCopied] = useState(false)
19
-
20
- const onCopy = async () => {
21
- try {
22
- await navigator.clipboard.writeText(typeof text === 'function' ? text() : String(text ?? ''))
23
- setCopied(true)
24
- setTimeout(() => setCopied(false), 2000)
25
- } catch {
26
- /* clipboard blocked — silent */
27
- }
28
- }
29
-
30
- return (
31
- <button
32
- type="button"
33
- className={`kol-copy-btn ${className}`.trim()}
34
- onClick={onCopy}
35
- aria-label={copied ? 'Copied' : 'Copy to clipboard'}
36
- title={copied ? 'Copied' : 'Copy'}
37
- {...props}
38
- >
39
- <Icon name={copied ? 'check' : 'copy'} size={16} />
40
- </button>
41
- )
42
- }