@kolkrabbi/kol-component 0.97.3 → 0.98.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.97.3",
3
+ "version": "0.98.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/atoms/Tag.jsx CHANGED
@@ -53,7 +53,8 @@ export default function Tag({
53
53
  icon,
54
54
  onRemove,
55
55
  onClick,
56
- className = ''
56
+ className = '',
57
+ ...props
57
58
  }) {
58
59
  const isInteractive = !!(onClick || onRemove)
59
60
  const Element = isInteractive ? 'button' : 'span'
@@ -92,6 +93,7 @@ export default function Tag({
92
93
 
93
94
  return (
94
95
  <Element
96
+ {...props}
95
97
  type={isInteractive ? 'button' : undefined}
96
98
  className={classes}
97
99
  onClick={onClick}
package/src/index.js CHANGED
@@ -141,6 +141,8 @@ export { default as SettingsPanel, SettingsRow, SettingsSwitch, SettingsChoice,
141
141
  export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
142
142
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
143
143
  export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
144
+ export { default as KindPreview } from './molecules/KindPreview.jsx'
145
+ export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL } from './utilities/mediaKinds.js'
144
146
  export { default as RecordManager } from './organisms/RecordManager.jsx'
145
147
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
146
148
  export { default as Table } from './organisms/Table.jsx'
@@ -0,0 +1,93 @@
1
+ import { useEffect, useState } from 'react'
2
+ import AudioPlayer from '../atoms/AudioPlayer.jsx'
3
+ import HlsVideo from '../atoms/HlsVideo.jsx'
4
+ import CodeBlock from './CodeBlock.jsx'
5
+ import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
6
+ import { kindOf as defaultKindOf, extOf as defaultExtOf, KIND_LABEL } from '../utilities/mediaKinds.js'
7
+
8
+ /* taxonomy-ok: molecule — nests the DS media atoms + CodeBlock (relative). */
9
+
10
+ /**
11
+ * KindPreview — a preview for any kind of file: kol-r2b2's `KindPreview.jsx`,
12
+ * promoted 2026-08-27 (SettingsPanelChromeAndColumnPreview). Before it, anything
13
+ * that was not an image or a video showed a grey box with the word "text".
14
+ * HLS → `HlsVideo` (inert — the DS's background-video atom, preview only),
15
+ * audio → `AudioPlayer`, text / code → `CodeBlock` (language by extension;
16
+ * markdown as code — the DS has no markdown renderer in this tier), the rest
17
+ * → `AssetPlaceholder`. Images are the caller's (ColumnBrowser keeps its own
18
+ * `<img>` so it can read the dimensions). Text fetches cap at `textLimit`.
19
+ *
20
+ * @param {object} o the object — `{ key, contentType?, displayKey?, segmentCount? }`
21
+ * @param {Function} urlOf (o) => string — the object's public URL (default: `o.url`)
22
+ * @param {string} poster a poster URL for HLS (the sibling image)
23
+ * @param {Function} kindOf · extOf classification seams (defaults: the DS mediaKinds)
24
+ * @param {Object} kindLabel kind → label
25
+ * @param {number} textLimit bytes of text fetched for a preview (default 200 KB)
26
+ */
27
+ const TEXT_LIMIT = 200 * 1024
28
+ const LANG = {
29
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
30
+ ts: 'typescript', tsx: 'typescript', json: 'json', yaml: 'yaml', yml: 'yaml',
31
+ css: 'css', html: 'html', sh: 'bash', py: 'python', md: 'markdown',
32
+ csv: 'text', tsv: 'text', pgn: 'text', txt: 'text', xml: 'xml',
33
+ }
34
+
35
+ function useTextContent(url, enabled, limit) {
36
+ const [result, setResult] = useState({ url: null, text: '', error: null, truncated: false })
37
+ useEffect(() => {
38
+ if (!enabled || !url) return undefined
39
+ let cancelled = false
40
+ const controller = new AbortController()
41
+ fetch(url, { signal: controller.signal })
42
+ .then(async (res) => {
43
+ if (!res.ok) throw new Error(`${res.status}`)
44
+ const size = Number(res.headers.get('content-length') || 0)
45
+ const body = await res.text()
46
+ if (cancelled) return
47
+ const truncated = body.length > limit || size > limit
48
+ setResult({ url, text: truncated ? body.slice(0, limit) : body, error: null, truncated })
49
+ })
50
+ .catch((e) => { if (!cancelled && e.name !== 'AbortError') setResult({ url, text: '', error: e.message, truncated: false }) })
51
+ return () => { cancelled = true; controller.abort() }
52
+ }, [url, enabled, limit])
53
+ return { ...result, loading: enabled && result.url !== url }
54
+ }
55
+
56
+ export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf = defaultKindOf, extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT }) {
57
+ const kind = kindOf(o)
58
+ const url = urlOf(o)
59
+ const ext = extOf(o.key)
60
+ const name = o.displayKey ?? o.key
61
+ const isText = kind === 'text' || kind === 'code'
62
+ const { loading, text, error, truncated } = useTextContent(url, isText, textLimit)
63
+
64
+ if (kind === 'playlist') {
65
+ return (
66
+ <div className="flex flex-col items-center gap-2">
67
+ <HlsVideo src={url} poster={poster} className="max-w-full max-h-[70vh] rounded" />
68
+ <span className="kol-mono-12 text-meta">HLS stream · playback is preview-only, no controls</span>
69
+ </div>
70
+ )
71
+ }
72
+ if (kind === 'audio') {
73
+ return (
74
+ <div className="flex justify-center p-8">
75
+ <AudioPlayer src={url} label={name} className="items-center w-[420px] max-w-full" />
76
+ </div>
77
+ )
78
+ }
79
+ if (isText) {
80
+ if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
81
+ if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
82
+ return (
83
+ <div className="max-w-[80ch] max-h-[78vh] overflow-y-auto">
84
+ <CodeBlock code={text} language={LANG[ext] || 'text'} filename={name} />
85
+ {truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
86
+ </div>
87
+ )
88
+ }
89
+ if (kind === 'segments') {
90
+ return <AssetPlaceholder category="HLS" name={`${o.segmentCount} segments`} note="STREAM CHUNKS" className="w-[420px] max-w-full" />
91
+ }
92
+ return <AssetPlaceholder category={kindLabel[kind] || 'file'} name={name} note={ext ? ext.toUpperCase() : 'FILE'} className="w-[420px] max-w-full" />
93
+ }
@@ -151,7 +151,7 @@ export default function ShellDrawer({
151
151
  tabIndex={-1}
152
152
  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-2xl' : ''} ${
153
153
  side === 'right' ? 'right-0 border-l' : 'left-0 border-r'
154
- } border-fg-08 ${width == null ? 'w-full' : ''} ${motionPanel} ${className}`}
154
+ } border-oq-08 ${width == null ? 'w-full' : ''} ${motionPanel} ${className}`}
155
155
  style={width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
156
156
  >
157
157
  {/* closeSide="start": the reference sets the × glyph ~9px deeper than
@@ -1,5 +1,7 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
+ import KindPreview from '../molecules/KindPreview.jsx'
4
+ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
3
5
 
4
6
  /**
5
7
  * ColumnBrowser — Finder-style Miller columns over a flat key space (kol-r2b2's
@@ -41,17 +43,13 @@ import { Icon } from '@kolkrabbi/kol-icons'
41
43
  * @param {Object} kindLabel kind → label shown when there is no visual preview
42
44
  * @param {Function} formatSize (bytes) => string
43
45
  * @param {Function} partition (objects, level) => { folders: string[], files: object[] }
46
+ * @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay); without it images render the organism's <img>, everything else the DS KindPreview
44
47
  * @param {string} className extra classes on the browser
45
48
  */
46
49
 
47
- const defaultKindOf = (o) => {
48
- const ct = o.contentType || ''
49
- if (ct.startsWith('image/')) return 'image'
50
- if (ct.startsWith('video/')) return 'video'
51
- if (ct.startsWith('audio/')) return 'audio'
52
- return 'file'
53
- }
54
- const DEFAULT_KIND_LABEL = { image: 'image', video: 'video', audio: 'audio', file: 'file' }
50
+ /* the DS kinds (mediaKinds — kol-r2b2's classification, promoted 2026-08-27) */
51
+ const defaultKindOf = dsKindOf
52
+ const DEFAULT_KIND_LABEL = DS_KIND_LABEL
55
53
  const defaultFormatSize = (bytes) => {
56
54
  if (bytes == null) return '—'
57
55
  if (bytes < 1024) return `${bytes} B`
@@ -74,7 +72,7 @@ const defaultPartition = (objects, prefix) => {
74
72
  const isImage = (o) => (o.contentType || '').startsWith('image/')
75
73
 
76
74
  /* kol-icons has no `audio` glyph yet — audio rows wear `file` until it does */
77
- const COL_ICON = { image: 'image', video: 'video', audio: 'file' }
75
+ const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'video' }
78
76
 
79
77
  function Row({ icon, label, active, cursor = false, trailing, onClick, muted = false }) {
80
78
  return (
@@ -97,7 +95,7 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
97
95
  )
98
96
  }
99
97
 
100
- function Preview({ o, urlOf, kindOf, kindLabel, formatSize }) {
98
+ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
101
99
  // Pixel size comes from the loaded image itself — the bucket stores none.
102
100
  const [dims, setDims] = useState(null)
103
101
  const src = isImage(o) ? urlOf?.(o) : null
@@ -110,8 +108,12 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize }) {
110
108
  ]
111
109
  return (
112
110
  <div className="kol-column-browser-preview w-[320px] shrink-0 overflow-y-auto p-4 flex flex-col gap-4">
113
- <div className="w-full aspect-square bg-fg-04 rounded flex items-center justify-center overflow-hidden">
114
- {src ? (
111
+ {/* the media frame: an image is the organism's own <img> (it reads the
112
+ * dimensions); anything else is `renderPreview(o)` or the DS KindPreview
113
+ * (video · audio · code · text — SettingsPanelChromeAndColumnPreview,
114
+ * 2026-08-27). Dimensions also come off any <img> a custom node loads. */}
115
+ {src && !renderPreview ? (
116
+ <div className="w-full aspect-square bg-fg-04 rounded flex items-center justify-center overflow-hidden">
115
117
  <img
116
118
  src={src}
117
119
  alt=""
@@ -119,10 +121,15 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize }) {
119
121
  loading="lazy"
120
122
  onLoad={(e) => setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight })}
121
123
  />
122
- ) : (
123
- <span className="kol-mono-12 text-fg-48">{kindLabel[kindOf(o)] || 'file'}</span>
124
- )}
125
- </div>
124
+ </div>
125
+ ) : (
126
+ <div
127
+ className="kol-column-browser-media w-full min-h-[160px] max-h-[60vh] overflow-auto rounded bg-fg-04 flex items-center justify-center"
128
+ onLoadCapture={(e) => { if (e.target?.tagName === 'IMG') setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight }) }}
129
+ >
130
+ {renderPreview ? renderPreview(o) : <KindPreview o={o} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} />}
131
+ </div>
132
+ )}
126
133
  <p className="kol-mono-12 text-fg-default break-all">{o.displayKey ?? o.key}</p>
127
134
  <dl className="flex flex-col gap-1">
128
135
  {facts.map(([k, v]) => (
@@ -148,6 +155,7 @@ export default function ColumnBrowser({
148
155
  kindLabel = DEFAULT_KIND_LABEL,
149
156
  formatSize = defaultFormatSize,
150
157
  partition = defaultPartition,
158
+ renderPreview,
151
159
  className = '',
152
160
  }) {
153
161
  const [picked, setPicked] = useState(null)
@@ -310,7 +318,7 @@ export default function ColumnBrowser({
310
318
  </ul>
311
319
  )
312
320
  })}
313
- {shown && <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} />}
321
+ {shown && <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} />}
314
322
  </div>
315
323
  )
316
324
  }
@@ -1,4 +1,5 @@
1
1
  import Button from '../atoms/Button.jsx'
2
+ import Tag from '../atoms/Tag.jsx'
2
3
  import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
3
4
  import ToggleSwitch from '../atoms/ToggleSwitch.jsx'
4
5
  import ShellDrawer from '../molecules/ShellDrawer.jsx'
@@ -54,13 +55,17 @@ export default function SettingsPanel({
54
55
  }) {
55
56
  const header = (
56
57
  <div className="flex min-w-0 flex-col">
57
- {title && <span className="kol-mono-12 text-emphasis">{title}</span>}
58
- {subtitle && <span className="kol-mono-10 text-meta">{subtitle}</span>}
58
+ {/* THE APP REGISTER (SettingsPanelChromeAndColumnPreview, kol-r2b2 2026-08-27
59
+ * user: "all wrong font styles"): the collection beside the panel is the
60
+ * reference — helper-14 uppercase titles, the eyebrow for section labels,
61
+ * mono-12 rows and hints. No mono-10 anywhere. */}
62
+ {title && <span className="kol-helper-14 uppercase text-emphasis">{title}</span>}
63
+ {subtitle && <span className="kol-mono-12 text-meta">{subtitle}</span>}
59
64
  </div>
60
65
  )
61
66
  const body = (
62
67
  <>
63
- {intro && <p className="kol-mono-10 text-meta">{intro}</p>}
68
+ {intro && <p className="kol-mono-12 text-meta">{intro}</p>}
64
69
  <div className="flex flex-col gap-5">{children}</div>
65
70
  {footer && <div className="mt-2">{footer}</div>}
66
71
  </>
@@ -102,8 +107,8 @@ export function SettingsRow({ label, hint, children }) {
102
107
  return (
103
108
  <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-6 py-1">
104
109
  <div className="flex min-w-0 flex-col">
105
- <span className="kol-mono-12 text-body">{label}</span>
106
- {hint && <span className="kol-mono-10 text-meta">{hint}</span>}
110
+ <span className="kol-mono-12 text-emphasis">{label}</span>
111
+ {hint && <span className="kol-mono-12 text-meta">{hint}</span>}
107
112
  </div>
108
113
  <div className="shrink-0">{children}</div>
109
114
  </div>
@@ -132,10 +137,10 @@ export function SettingsChoice({ options = [], value, onChange, ariaLabel }) {
132
137
  return <SegmentedToggle size="sm" value={value} onChange={onChange} options={opts} ariaLabel={ariaLabel} />
133
138
  }
134
139
 
135
- /* The toggle-chip pattern `.kol-control` sm, filled when on, meta ink when
136
- * off (ViewToggle's text variant wears the same string; MediaLibrary's kind
137
- * chips too). ponytail: a `ControlChip` atom is the upgrade if a third
138
- * consumer types this. */
140
+ /* The chips are the DS `Tag` ("the tag ticket" — SettingsPanelChromeAndColumnPreview,
141
+ * user 2026-08-27: same chrome as Tag everywhere else, not a third look): `md`
142
+ * (helper-12), `inverse` when on, `secondary` when off, as a button. The old
143
+ * `.kol-control` string stays exported for consumers that typed it. */
139
144
  export const CHIP_CLS = 'kol-control kol-control-sm kol-mono-12'
140
145
  export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text-meta hover:text-emphasis'}`
141
146
 
@@ -151,16 +156,18 @@ export function SettingsChipRow({ options = [], selected = [], onToggle }) {
151
156
  return (
152
157
  <div className="flex flex-wrap gap-1">
153
158
  {options.map((o) => (
154
- <button
159
+ <Tag
155
160
  key={String(o.value)}
156
- type="button"
161
+ size="md"
162
+ hash={false}
163
+ variant={on.has(o.value) ? 'inverse' : 'secondary'}
164
+ active={on.has(o.value)}
157
165
  aria-pressed={on.has(o.value)}
158
166
  onClick={() => onToggle?.(o.value)}
159
- className={chipCls(on.has(o.value))}
160
167
  >
161
168
  {o.label ?? String(o.value)}
162
169
  {o.count > 0 && <span className="text-meta"> {o.count}</span>}
163
- </button>
170
+ </Tag>
164
171
  ))}
165
172
  </div>
166
173
  )
@@ -170,7 +177,7 @@ export function SettingsChipRow({ options = [], selected = [], onToggle }) {
170
177
  export function SettingsFooter({ customised = false, onReset, resetLabel = 'Reset to defaults' }) {
171
178
  return (
172
179
  <div className="flex items-center justify-between">
173
- <span className="kol-mono-10 text-meta">{customised ? 'customised' : 'defaults'}</span>
180
+ <span className="kol-mono-12 text-meta">{customised ? 'customised' : 'defaults'}</span>
174
181
  {onReset && <Button variant="ghost" size="sm" onClick={onReset}>{resetLabel}</Button>}
175
182
  </div>
176
183
  )
@@ -0,0 +1,48 @@
1
+ /* Media classification — kol-r2b2's `lib/media.js` kinds, promoted 2026-08-27
2
+ * (SettingsPanelChromeAndColumnPreview) so ColumnBrowser and KindPreview
3
+ * classify the same way the app does. contentType is unreliable for text /
4
+ * code (B2 hands back application/octet-stream), so the extension decides. */
5
+
6
+ const EXT_KINDS = {
7
+ json: 'text', yaml: 'text', yml: 'text', txt: 'text', md: 'text', csv: 'text',
8
+ tsv: 'text', pgn: 'text', xml: 'text', svg: 'image',
9
+ js: 'code', mjs: 'code', cjs: 'code', ts: 'code', jsx: 'code', tsx: 'code',
10
+ css: 'code', html: 'code', sh: 'code', py: 'code',
11
+ m3u8: 'playlist',
12
+ woff: 'font', woff2: 'font', ttf: 'font', otf: 'font',
13
+ zip: 'archive', gz: 'archive', tar: 'archive', rar: 'archive', '7z': 'archive',
14
+ }
15
+
16
+ export function extOf(key = '') {
17
+ const base = key.slice(key.lastIndexOf('/') + 1)
18
+ const dot = base.lastIndexOf('.')
19
+ return dot === -1 ? '' : base.slice(dot + 1).toLowerCase()
20
+ }
21
+
22
+ const SYSTEM_NAMES = new Set(['.ds_store', 'thumbs.db', 'desktop.ini', '.bzempty'])
23
+ export function isSystemFile(key = '') {
24
+ return SYSTEM_NAMES.has(key.slice(key.lastIndexOf('/') + 1).toLowerCase())
25
+ }
26
+
27
+ export function kindOf(o) {
28
+ if (o.forcedKind) return o.forcedKind
29
+ if (isSystemFile(o.key)) return 'system'
30
+ const ct = o.contentType || ''
31
+ if (ct.startsWith('image/')) return 'image'
32
+ if (ct.startsWith('video/')) return 'video'
33
+ if (ct.startsWith('audio/')) return 'audio'
34
+ const ext = extOf(o.key)
35
+ if (EXT_KINDS[ext]) return EXT_KINDS[ext]
36
+ if (ct.startsWith('text/')) return 'text'
37
+ if (ct.startsWith('font/')) return 'font'
38
+ if (ct === 'application/json') return 'text'
39
+ return 'other'
40
+ }
41
+
42
+ export const KINDS = ['image', 'video', 'audio', 'text', 'code', 'playlist', 'font', 'archive', 'other']
43
+
44
+ export const KIND_LABEL = {
45
+ image: 'image', video: 'video', audio: 'audio', text: 'text', code: 'code',
46
+ playlist: 'HLS', font: 'font', archive: 'archive', segments: 'HLS segments',
47
+ system: 'system', other: 'file',
48
+ }