@kolkrabbi/kol-component 0.215.0 → 0.217.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.215.0",
4
- "description": "KOL design-system components \u2014 atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
3
+ "version": "0.217.0",
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",
7
7
  "main": "./src/index.js",
@@ -23,6 +23,7 @@
23
23
  "dependencies": {
24
24
  "@floating-ui/react": "^0.27.19",
25
25
  "embla-carousel-react": "^8.6.0",
26
+ "pdfjs-dist": "^6.3.289",
26
27
  "react-syntax-highlighter": "^16.1.1"
27
28
  },
28
29
  "peerDependencies": {
@@ -34,7 +35,7 @@
34
35
  "react-dom": "^18.3.0 || ^19.0.0"
35
36
  },
36
37
  "devDependencies": {
37
- "@kolkrabbi/kol-icons": "workspace:^"
38
+ "@kolkrabbi/kol-icons": "^0.27.1"
38
39
  },
39
40
  "files": [
40
41
  "src",
@@ -56,4 +57,4 @@
56
57
  "access": "public"
57
58
  },
58
59
  "sideEffects": false
59
- }
60
+ }
@@ -3,6 +3,7 @@ import { DURATION } from '../utilities/motion.js'
3
3
  import gsap from 'gsap'
4
4
  import { Icon } from '@kolkrabbi/kol-icons'
5
5
  import { glyphSize } from '../hooks/glyphLadders.js'
6
+ import { Tooltip } from '../utilities/Popover.jsx'
6
7
 
7
8
  /**
8
9
  * ActionButton — an icon control that CONFIRMS what it did (2026-08-15 user
@@ -179,16 +180,16 @@ export default function ActionButton({
179
180
 
180
181
  /* The element follows the affordance — IconFrame's contract, same reasoning:
181
182
  * a link must be a real <a> for middle-click, focus order and screen readers. */
182
- if (href) {
183
- return (
184
- <a className={cls} href={href} onClick={handle} aria-label={aria} title={aria} {...rest}>
185
- {glyph}
186
- </a>
187
- )
188
- }
189
- return (
190
- <button type="button" className={cls} onClick={handle} aria-label={aria} title={aria} {...rest}>
183
+ const control = href ? (
184
+ <a className={cls} href={href} onClick={handle} aria-label={aria} {...rest}>
185
+ {glyph}
186
+ </a>
187
+ ) : (
188
+ <button type="button" className={cls} onClick={handle} aria-label={aria} {...rest}>
191
189
  {glyph}
192
190
  </button>
193
191
  )
192
+ /* The label shows as the DS Tooltip, not a native `title` (kol-client-olina 2026-09-22) — and
193
+ * it follows the swap, so a Copy URL reads "Copied" while the check is up. */
194
+ return aria ? <Tooltip label={aria}>{control}</Tooltip> : control
194
195
  }
@@ -0,0 +1,31 @@
1
+ import { Icon } from '@kolkrabbi/kol-icons'
2
+
3
+ /**
4
+ * FileIcon — the file as a page: folded corner, the kind's glyph, the extension under it.
5
+ * Finder's generic document icon (user 2026-09-23: *"a generic preview for that like wav and
6
+ * the type below that finder has"*). What a file shows when it has nothing of its own to draw:
7
+ * audio without cover art, an empty file, a kind no renderer handles.
8
+ *
9
+ * Drawn in one SVG so the label scales with the page — the box sets the size, never the type.
10
+ *
11
+ * @param {string} ext the extension, shown as authored upper-case (`WAV`)
12
+ * @param {string} glyph a kol-icons name drawn on the page, or none
13
+ * @param {string} className sizes the icon; it keeps its own 100:128 ratio
14
+ */
15
+ export default function FileIcon({ ext, glyph, className = '' }) {
16
+ const label = ext ? String(ext).toUpperCase() : ''
17
+ /* a long extension (GITIGNORE) is FITTED to the page, never cut — "GITIG" named nothing */
18
+ const fit = label.length > 5 ? { textLength: 76, lengthAdjust: 'spacingAndGlyphs' } : {}
19
+ return (
20
+ <svg className={`kol-file-icon ${className}`.trim()} viewBox="0 0 100 128" role="img" aria-label={label ? `${label} file` : 'File'}>
21
+ <path className="kol-file-icon-page" d="M8 2h58l32 32v86a6 6 0 0 1-6 6H8a6 6 0 0 1-6-6V8a6 6 0 0 1 6-6z" />
22
+ <path className="kol-file-icon-fold" d="M66 2v26a6 6 0 0 0 6 6h26z" />
23
+ {glyph && (
24
+ <foreignObject x="30" y={label ? 42 : 52} width="40" height="40">
25
+ <Icon name={glyph} size={40} className="kol-file-icon-glyph" />
26
+ </foreignObject>
27
+ )}
28
+ {label && <text className="kol-file-icon-ext" x="50" y="108" textAnchor="middle" {...fit}>{label}</text>}
29
+ </svg>
30
+ )
31
+ }
@@ -1,5 +1,7 @@
1
+ import { Fragment } from 'react'
1
2
  import { Icon } from '@kolkrabbi/kol-icons'
2
3
  import { toneClass } from '../utilities/tone.js'
4
+ import { Tooltip } from '../utilities/Popover.jsx'
3
5
 
4
6
  /**
5
7
  * ViewToggle — control for switching between view modes.
@@ -63,7 +65,6 @@ const ViewToggle = ({
63
65
  onClick={() => onViewChange(next)}
64
66
  className={cls}
65
67
  aria-pressed={isOn}
66
- title={isOn ? onOpt.label : offOpt.label}
67
68
  >
68
69
  <span className="grid">
69
70
  <span className={`col-start-1 row-start-1 ${isOn ? '' : 'invisible'}`}>{onOpt.label}</span>
@@ -100,22 +101,39 @@ const ViewToggle = ({
100
101
 
101
102
  return (
102
103
  <div className={containerClasses}>
103
- {options.map((option) => (
104
- <button
105
- key={option.value}
106
- onClick={() => onViewChange(option.value)}
107
- className={buttonClasses(viewMode === option.value)}
108
- aria-label={option.label}
109
- aria-pressed={viewMode === option.value}
110
- title={option.label}
111
- >
112
- {isIconVariant && option.icon ? (
113
- <Icon name={option.icon} size={14} variant={iconVariant} />
114
- ) : (
115
- option.label
116
- )}
117
- </button>
118
- ))}
104
+ {options.map((option) => {
105
+ /* AN OPTION CAN BE ITS OWN BUTTON (2026-09-23): `onClick` (+ optional `pressed`) makes it an
106
+ * independent action in the same well — the media pages' filter and search, which sit beside
107
+ * the view switch and must be the same height and chip, not a second hand-built strip. */
108
+ const active = option.onClick ? !!option.pressed : viewMode === option.value
109
+ const button = (
110
+ <button
111
+ key={option.value}
112
+ onClick={option.onClick ?? (() => onViewChange(option.value))}
113
+ className={buttonClasses(active)}
114
+ aria-label={option.label}
115
+ aria-pressed={option.onClick && option.pressed == null ? undefined : active}
116
+ >
117
+ {isIconVariant && option.icon ? (
118
+ <Icon name={option.icon} size={14} variant={iconVariant} />
119
+ ) : (
120
+ option.label
121
+ )}
122
+ </button>
123
+ )
124
+ /* THE DS TOOLTIP, NOT `title` (native-title-tooltips-in-ds-components, kol-client-olina
125
+ * 2026-09-22). Only an ICON option gets one — a text option already shows its label, and
126
+ * a tooltip repeating it is noise; the native `title` did exactly that. */
127
+ const item = isIconVariant && option.icon
128
+ ? <Tooltip key={option.value} label={option.label}>{button}</Tooltip>
129
+ : button
130
+ /* `dividerBefore` (2026-09-23): a hairline in the well between two options that are not the
131
+ * same kind of thing — filter and search are two independent actions, and two bare chips
132
+ * side by side read as one control. A light hairline (oq-16, opaque, flips with the theme) — the dark one vanished. */
133
+ return isIconVariant && option.dividerBefore
134
+ ? <Fragment key={option.value}><span aria-hidden="true" className="w-px h-4 shrink-0 bg-oq-16" />{item}</Fragment>
135
+ : item
136
+ })}
119
137
  </div>
120
138
  )
121
139
  }
@@ -0,0 +1,93 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * useMarquee — drag a band across a list and select what it touches, Finder's
5
+ * rubber band (user 2026-09-22: *"at least in column and row a drag to
6
+ * highlight/select multiple items"*).
7
+ *
8
+ * The container is whatever gets `props`; every selectable child carries
9
+ * `data-marquee-key`. The band is drawn by the caller from `rect` — one
10
+ * absolutely-positioned div inside a `relative` container — so the hook owns
11
+ * the geometry and the DOM stays the caller's.
12
+ *
13
+ * A drag that never leaves its starting pixel is a CLICK and is left alone:
14
+ * the threshold is what keeps row clicks, double-clicks and the context menu
15
+ * working. Modifier-drag adds to the selection instead of replacing it, which
16
+ * is the one thing every file manager agrees on.
17
+ *
18
+ * @param {Function} onSelect (keys: string[], additive: boolean) => void
19
+ * @param {boolean} enabled false → the container behaves exactly as before
20
+ * @returns {{ ref, rect, props }} rect is `{left, top, width, height}` in container space, or null
21
+ */
22
+ const THRESHOLD = 4
23
+
24
+ export default function useMarquee({ onSelect, enabled = true } = {}) {
25
+ const ref = useRef(null)
26
+ const start = useRef(null)
27
+ /* A finished band ends in a `click` on the container, and the container's own click is
28
+ * "you clicked the background, so deselect" — which would undo the band the moment it landed.
29
+ * The flag swallows exactly that one click. */
30
+ const dragged = useRef(false)
31
+ const [rect, setRect] = useState(null)
32
+
33
+ const onPointerDown = useCallback((e) => {
34
+ if (!enabled || e.button !== 0) return
35
+ const box = ref.current
36
+ if (!box) return
37
+ /* A DRAG THAT BEGINS ON A ROW IS NOT A MARQUEE — it is a row drag (move to a folder), which
38
+ * this must not steal. The band starts on the list's own background. */
39
+ /* …unless the row says only part of it is the item (`data-hit-zone`, the list view's rows —
40
+ * Finder's: the file ends where its name ends). Then only a press on `[data-hit]` is a row. */
41
+ const keyEl = e.target.closest?.('[data-marquee-key]')
42
+ if (keyEl && (!keyEl.hasAttribute('data-hit-zone') || e.target.closest('[data-hit]'))) return
43
+ const origin = box.getBoundingClientRect()
44
+ start.current = {
45
+ x: e.clientX - origin.left + box.scrollLeft,
46
+ y: e.clientY - origin.top + box.scrollTop,
47
+ additive: e.metaKey || e.ctrlKey || e.shiftKey,
48
+ moved: false,
49
+ }
50
+
51
+ const move = (ev) => {
52
+ const s = start.current
53
+ if (!s) return
54
+ const now = box.getBoundingClientRect()
55
+ const x = ev.clientX - now.left + box.scrollLeft
56
+ const y = ev.clientY - now.top + box.scrollTop
57
+ if (!s.moved && Math.abs(x - s.x) < THRESHOLD && Math.abs(y - s.y) < THRESHOLD) return
58
+ s.moved = true
59
+ const band = { left: Math.min(s.x, x), top: Math.min(s.y, y), width: Math.abs(x - s.x), height: Math.abs(y - s.y) }
60
+ setRect(band)
61
+ const keys = []
62
+ for (const el of box.querySelectorAll('[data-marquee-key]')) {
63
+ const r = el.getBoundingClientRect()
64
+ const top = r.top - now.top + box.scrollTop
65
+ const bottom = top + r.height
66
+ const left = r.left - now.left + box.scrollLeft
67
+ const right = left + r.width
68
+ if (bottom > band.top && top < band.top + band.height && right > band.left && left < band.left + band.width) {
69
+ keys.push(el.getAttribute('data-marquee-key'))
70
+ }
71
+ }
72
+ onSelect?.(keys, s.additive)
73
+ }
74
+
75
+ const up = () => {
76
+ window.removeEventListener('pointermove', move)
77
+ window.removeEventListener('pointerup', up)
78
+ dragged.current = !!start.current?.moved
79
+ start.current = null
80
+ setRect(null)
81
+ }
82
+ window.addEventListener('pointermove', move)
83
+ window.addEventListener('pointerup', up)
84
+ }, [enabled, onSelect])
85
+
86
+ const onClickCapture = useCallback((e) => {
87
+ if (!dragged.current) return
88
+ dragged.current = false
89
+ e.stopPropagation()
90
+ }, [])
91
+
92
+ return { ref, rect, props: { onPointerDown, onClickCapture } }
93
+ }
package/src/index.js CHANGED
@@ -24,6 +24,7 @@ export { default as Badge } from './atoms/Badge.jsx'
24
24
  export { default as Button } from './atoms/Button.jsx'
25
25
  export { default as CloseButton } from './utilities/CloseButton.jsx'
26
26
  export { default as ActionButton } from './atoms/ActionButton.jsx'
27
+ export { default as FileIcon } from './atoms/FileIcon.jsx'
27
28
  export { default as SizeOrDownload } from './atoms/SizeOrDownload.jsx'
28
29
  export { default as SortHeader } from './atoms/SortHeader.jsx'
29
30
  export { default as SortControls } from './molecules/SortControls.jsx'
@@ -48,6 +49,7 @@ export { default as LabeledControl } from './molecules/LabeledControl.jsx'
48
49
  export { default as OverlayGlassPanel } from './utilities/OverlayGlassPanel.jsx'
49
50
  export { default as Pill } from './atoms/Pill.jsx'
50
51
  export { usePopover, PopoverPanel, Tooltip } from './utilities/Popover.jsx'
52
+ export { default as ContextMenu, useContextMenu } from './utilities/ContextMenu.jsx'
51
53
  export { default as ProsePreview } from './utilities/ProsePreview.jsx'
52
54
  export { default as QuantityInput } from './molecules/QuantityInput.jsx'
53
55
  export { default as RotaryDial } from './atoms/RotaryDial.jsx'
@@ -164,6 +166,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
164
166
  * rendered both mobile viewer views and was module-private, so every consumer
165
167
  * needing a phone viewer hand-rolled one. */
166
168
  export { MediaInspector } from './organisms/MediaLibraryPages.jsx'
169
+ export { default as MediaLibraryExplorer } from './organisms/MediaLibraryExplorer.jsx'
167
170
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
168
171
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
169
172
  export { default as SettingsPanel, LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
@@ -178,6 +181,8 @@ export { default as AudioPreview, AudioTile, VideoTile, formatLength } from './m
178
181
  export { default as VideoSheet } from './molecules/VideoSheet.jsx'
179
182
  export { default as PlaybackBar } from './molecules/PlaybackBar.jsx'
180
183
  export { default as AudioSheet } from './molecules/AudioSheet.jsx'
184
+ export { default as QuickLookFrame } from './molecules/QuickLookFrame.jsx'
185
+ export { default as MediaTile } from './molecules/MediaTile.jsx'
181
186
  export { default as DocPage } from './molecules/DocPage.jsx'
182
187
  export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
183
188
  export { default as ProfileCard } from './molecules/ProfileCard.jsx'
@@ -27,6 +27,8 @@ import { readCover } from '../utilities/id3.js';
27
27
  * @param {string} poster VideoTile — the poster URL
28
28
  * @param {Function} onDuration AudioPreview — (seconds) => void once metadata lands
29
29
  * @param {string} className AudioPreview — wrapper classes (the width is the consumer's)
30
+ * @param {ReactNode} fallback AudioTile — shown when the file carries no cover art (2026-09-23:
31
+ * it was an empty square, so a WAV read as a missing file)
30
32
  */
31
33
  export const formatLength = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
32
34
  const fmt = formatLength;
@@ -49,14 +51,14 @@ function PlayDisc({ playing, onClick }) {
49
51
  );
50
52
  }
51
53
 
52
- export function AudioTile({ src }) {
54
+ export function AudioTile({ src, fallback = null }) {
53
55
  const ref = useRef(null);
54
56
  const [playing, setPlaying] = useState(false);
55
57
  const cover = useCover(src);
56
58
  return (
57
59
  <div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
58
60
  <audio ref={ref} src={src} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
59
- {cover && <img src={cover} alt="" className="absolute inset-0 w-full h-full object-cover" />}
61
+ {cover ? <img src={cover} alt="" className="absolute inset-0 w-full h-full object-cover" /> : fallback && <div className="absolute inset-0 flex items-center justify-center">{fallback}</div>}
60
62
  <PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
61
63
  </div>
62
64
  );
@@ -1,10 +1,11 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { Icon } from '@kolkrabbi/kol-icons'
3
2
  import usePlayback from '../hooks/usePlayback.js'
4
3
  import PlaybackBar, { clock } from './PlaybackBar.jsx'
4
+ import QuickLookFrame from './QuickLookFrame.jsx'
5
+ import FileIcon from '../atoms/FileIcon.jsx'
5
6
  import { readCover } from '../utilities/id3.js'
6
7
 
7
- /* taxonomy-ok: molecule — nests PlaybackBar (relative) + kol-icons' Icon. */
8
+ /* taxonomy-ok: molecule — nests PlaybackBar + QuickLookFrame (relative) + FileIcon (atom). */
8
9
 
9
10
  function useCover(src) {
10
11
  const [cover, setCover] = useState(null)
@@ -17,46 +18,31 @@ function useCover(src) {
17
18
  }
18
19
 
19
20
  /**
20
- * AudioSheet — audio in the overlay, ruled against the QuickTime audio reference
21
- * (PlaybackBarAndAudioSheet, kol-r2b2 2026-08-27), two variants:
22
- *
23
- * cover the same as VideoSheet — the artwork is the frame (`w-[min(78vh,100vw_-_10rem)]`,
24
- * square), the bar floating over its bottom edge (inset 16, radius 12)
25
- * sheet the QuickTime window — a dark plate (`bg-fg-ab-88`, `min(100vw − 10rem, 1000px)`),
26
- * the cover square left, `Time: mm:ss` beside it, the bar flush along the bottom
27
- *
28
- * Artwork = the file's embedded ID3 `APIC` cover (`readCover`); no cover → a light
29
- * square carrying the `music-note` glyph, the reference's. `autoPlay`; `onDuration`
30
- * for the overlay's facts. The `10rem` in the widths is the overlay's arrow gutter.
21
+ * AudioSheet — audio in the Quick Look window, Finder's layout (user 2026-09-23, the reference
22
+ * shot): the artwork square left, `Time: mm:ss` beside it, the QuickTime bar docked in the
23
+ * window's footer. Artwork = the file's embedded ID3 `APIC` cover (`readCover`); no cover →
24
+ * `FileIcon`, the same page the tile shows. The `cover` / `sheet` variants are gone — there is
25
+ * one window now, and it is the sheet.
31
26
  *
32
27
  * @param {string} src the audio URL
28
+ * @param {string} ext the extension, for the no-cover icon
33
29
  * @param {Function} onDuration (seconds) => void once metadata lands
34
- * @param {'cover'|'sheet'} variant
30
+ * @param {Object} frame the window's header — `QuickLookFrame`'s title · meta · actions · onClose
35
31
  */
36
- export default function AudioSheet({ src, onDuration, variant = 'cover' }) {
32
+ export default function AudioSheet({ src, ext, onDuration, frame }) {
37
33
  const { ref, handlers, bar } = usePlayback((el) => onDuration?.(el.duration))
38
34
  const cover = useCover(src)
39
- const audio = <audio ref={ref} src={src} autoPlay preload="metadata" {...handlers} />
40
- const art = cover
41
- ? <img src={cover} alt="" className="w-full h-full object-cover" />
42
- : <div className="w-full h-full flex items-center justify-center bg-ab-white text-fg-ab-32"><Icon name="music-note" size={64} /></div>
43
-
44
- if (variant === 'sheet') return (
45
- <div className="relative w-[min(100vw_-_10rem,1000px)] rounded overflow-hidden bg-fg-ab-88">
46
- {audio}
47
- <div className="flex items-center gap-16 p-10 pb-24">
48
- <div className="w-[min(50vh,380px)] aspect-square shrink-0 overflow-hidden">{art}</div>
49
- <span className="kol-mono-16 text-ab-white"><span className="opacity-48">Time: </span><b className="opacity-80">{clock(bar.duration)}</b></span>
50
- </div>
51
- <PlaybackBar {...bar} place="left-0 right-0 bottom-0" />
52
- </div>
53
- )
54
-
55
35
  return (
56
- <div className="relative max-w-full">
57
- {audio}
58
- <div className="w-[min(78vh,100vw_-_10rem)] aspect-square rounded overflow-hidden">{art}</div>
59
- <PlaybackBar {...bar} place="left-4 right-4 bottom-4 rounded-xl" />
60
- </div>
36
+ <QuickLookFrame {...frame} footer={<PlaybackBar {...bar} docked />}>
37
+ <audio ref={ref} src={src} preload="metadata" {...handlers} />
38
+ <div className="kol-quicklook-audio">
39
+ <div className="kol-quicklook-art">
40
+ {cover
41
+ ? <img src={cover} alt="" className="w-full h-full object-cover" />
42
+ : <FileIcon ext={ext} glyph="music-note" className="w-[52%]" />}
43
+ </div>
44
+ <span className="kol-mono-16 text-meta">Time: <b className="text-strong">{clock(bar.duration)}</b></span>
45
+ </div>
46
+ </QuickLookFrame>
61
47
  )
62
48
  }
@@ -77,7 +77,7 @@ const syntaxTheme = (foregroundToken = 80) => ({
77
77
  * for anything else. `md` is those exact values, named; `sm` is one step down
78
78
  * on both axes. Size is INDEPENDENT of `bare`: bare removes the frame, size
79
79
  * sets the box, and a bare block still has one. */
80
- export default function CodeBlock({ children, code: codeProp, language: languageProp, filename: filenameProp, value, bare = false, size = 'md' }) {
80
+ export default function CodeBlock({ children, code: codeProp, language: languageProp, filename: filenameProp, value, bare = false, size = 'md', copy = true }) {
81
81
  const code = String(value?.code ?? codeProp ?? children ?? '')
82
82
  const language = value?.language ?? languageProp ?? 'text'
83
83
  const filename = value?.filename ?? filenameProp
@@ -118,7 +118,9 @@ export default function CodeBlock({ children, code: codeProp, language: language
118
118
  >
119
119
  {code}
120
120
  </SyntaxHighlighter>
121
- <CopyButton text={code} className="kol-frame-control" />
121
+ {/* `copy={false}` (2026-09-23): a PREVIEW of a file is not a snippet to lift — the file's
122
+ * own copy action lives on its card (user: *"thats a feature in grid mode"*). */}
123
+ {copy && <CopyButton text={code} className="kol-frame-control" />}
122
124
  </div>
123
125
  </div>
124
126
  )
@@ -185,6 +185,12 @@ export default function ContentCard({
185
185
  expandedContent,
186
186
  selected = false,
187
187
  onClick,
188
+ /* RIGHT-CLICK AND DOUBLE-CLICK RIDE ALONG (user 2026-09-23: *"where are the right click options
189
+ * in grid view … CONSISTENCY"*). A card that takes a click takes the other two gestures the same
190
+ * surface offers on its rows — the file wall's tiles are the same objects its rows are, and a
191
+ * menu that exists on one and not the other is the inconsistency, not a missing feature. */
192
+ onContextMenu,
193
+ onDoubleClick,
188
194
  href,
189
195
  onNavigate,
190
196
  className = '',
@@ -455,6 +461,13 @@ export default function ContentCard({
455
461
  return (
456
462
  <article
457
463
  onClick={onClick}
464
+ onContextMenu={onContextMenu}
465
+ onDoubleClick={onDoubleClick}
466
+ /* SELECTED HAS TO SHOW (user 2026-09-23: a click in the grid "should select/highlight").
467
+ * `selected` only flipped a border colour on the variants that HAVE a border, and the file
468
+ * wall's cards have none — so the prop was true and the card looked untouched. The
469
+ * attribute is the hook; the theme paints it, once, for every variant. */
470
+ data-selected={selected || undefined}
458
471
  role="button"
459
472
  tabIndex={0}
460
473
  onKeyDown={(e) => {
@@ -158,6 +158,12 @@ export default function ContentRow({
158
158
  ratioAxis = 'width',
159
159
  selected = false,
160
160
  onClick,
161
+ /* RIGHT-CLICK AND DOUBLE-CLICK RIDE ALONG (user 2026-09-23: *"where are the right click options
162
+ * in grid view … CONSISTENCY"*). A card that takes a click takes the other two gestures the same
163
+ * surface offers on its rows — the file wall's tiles are the same objects its rows are, and a
164
+ * menu that exists on one and not the other is the inconsistency, not a missing feature. */
165
+ onContextMenu,
166
+ onDoubleClick,
161
167
  href,
162
168
  onNavigate,
163
169
  tagVariant,
@@ -308,6 +314,9 @@ export default function ContentRow({
308
314
  return (
309
315
  <div
310
316
  onClick={onClick}
317
+ onContextMenu={onContextMenu}
318
+ onDoubleClick={onDoubleClick}
319
+ data-selected={selected || undefined}
311
320
  role="button"
312
321
  tabIndex={0}
313
322
  onKeyDown={(e) => {
@@ -68,7 +68,7 @@ export default function DocFrontmatter({ metadata }) {
68
68
  return (
69
69
  <div key={key} className="flex items-baseline gap-4 py-1">
70
70
  <span className="flex items-center gap-2 min-w-[120px] kol-helper-12 text-meta">
71
- {icon && <Icon name={icon} size={14} />}
71
+ {icon && <Icon name={icon} size={14} className="text-oq-48" />}
72
72
  {FIELD_LABELS[key] ?? humanise(key)}
73
73
  </span>
74
74
  <span className="flex-1 min-w-0 [overflow-wrap:anywhere] kol-mono-12 text-strong">
@@ -3,6 +3,8 @@ import { AudioTile, VideoTile } from './AudioPreview.jsx'
3
3
  import HlsVideo from '../atoms/HlsVideo.jsx'
4
4
  import CodeBlock from './CodeBlock.jsx'
5
5
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
6
+ import FileIcon from '../atoms/FileIcon.jsx'
7
+ import PdfPage, { PdfDocument } from './PdfPage.jsx'
6
8
  import { kindOf as defaultKindOf, extOf as defaultExtOf, KIND_LABEL } from '../utilities/mediaKinds.js'
7
9
  import markdownToHtml from '../utilities/markdownToHtml.js'
8
10
  import { parseFrontmatter } from '../utilities/frontmatter.js'
@@ -24,7 +26,7 @@ import DocPage from './DocPage.jsx'
24
26
  * `DocPage` (DocPageAndKindShowcase 2026-08-27: the overlay's A-series page, the
25
27
  * column's zoomed frame; markdown's frontmatter rendered above the prose from
26
28
  * the same fetch), the rest
27
- * → `AssetPlaceholder`. Images are the caller's (ColumnBrowser keeps its own
29
+ * → `FileIcon` (the generic document). Images are the caller's (ColumnBrowser keeps its own
28
30
  * `<img>` so it can read the dimensions). Text fetches cap at `textLimit`.
29
31
  *
30
32
  * @param {object} o the object — `{ key, contentType?, displayKey?, segmentCount? }`
@@ -40,6 +42,8 @@ import DocPage from './DocPage.jsx'
40
42
  * @param {number} textLimit bytes of text fetched for a preview (default 200 KB)
41
43
  */
42
44
  const TEXT_LIMIT = 200 * 1024
45
+ /* The glyph a FileIcon carries per kind. A kind not listed gets the bare page + extension. */
46
+ export const KIND_GLYPH = { audio: 'music-note', video: 'video', image: 'image', code: 'code', json: 'code', yaml: 'code', font: 'type' }
43
47
  const LANG = {
44
48
  js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
45
49
  ts: 'typescript', tsx: 'typescript', json: 'json', yaml: 'yaml', yml: 'yaml',
@@ -68,7 +72,63 @@ function useTextContent(url, enabled, limit) {
68
72
  return { ...result, loading: enabled && result.url !== url }
69
73
  }
70
74
 
71
- export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf = (x) => x.url, poster, kindOf = defaultKindOf, extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT }) {
75
+ /* A FONT SHOWS ITSELF (user 2026-09-23: *"Font needs a preview, we have a big font based collection
76
+ * here"*). The browser loads the file with `FontFace` and the specimen sets it: `Aa`, then the
77
+ * alphabet and the figures. Scoped to a generated family so it can never collide with the app's
78
+ * type, and removed on unmount. Lifted from the media app's overview so the pane and Quick Look
79
+ * get it too — no `kol-foundry` dependency, which sits above this package. */
80
+ function useFontFace(src) {
81
+ const [family, setFamily] = useState({ src: null, name: null, failed: false })
82
+ useEffect(() => {
83
+ let live = true
84
+ let h = 0
85
+ for (let i = 0; i < src.length; i += 1) h = (h * 31 + src.charCodeAt(i)) | 0
86
+ const name = `kol-font-preview-${Math.abs(h)}`
87
+ const face = new FontFace(name, `url(${JSON.stringify(src)})`)
88
+ face.load()
89
+ .then((loaded) => { if (!live) return; document.fonts.add(loaded); setFamily({ src, name, failed: false }) })
90
+ .catch(() => { if (live) setFamily({ src, name: null, failed: true }) })
91
+ return () => { live = false; document.fonts.delete(face) }
92
+ }, [src])
93
+ return family.src === src ? family : { name: null, failed: false }
94
+ }
95
+ function FontSpecimen({ src, fit, fallback }) {
96
+ const { name, failed } = useFontFace(src)
97
+ if (failed) return fallback
98
+ if (!name) return <span className="kol-mono-12 text-meta">Loading the face…</span>
99
+ const big = fit === 'sheet'
100
+ return (
101
+ <div className={`kol-font-specimen${big ? ' kol-font-specimen--sheet' : ''}`} style={{ fontFamily: `"${name}"` }}>
102
+ <span className="kol-font-specimen-aa">Aa</span>
103
+ <span className="kol-font-specimen-set">ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789</span>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ /* A PDF IS A DOCUMENT AND SHOWS ITS FIRST PAGE (user 2026-09-23: *"pdf is not showing any
109
+ * preview … this is all mapped in finder dropbox google drive"*). Every one of those renders page
110
+ * one; we drew a placeholder card. `PdfPage` draws it with pdf.js.
111
+ *
112
+ * THE SIZE GATE is the honest part: pdf.js pulls the whole file to paint one page, which against
113
+ * a live bucket is a 40MB download for a thumbnail. Past the limit the file icon stands. */
114
+ const PDF_LIMIT = 8 * 1024 * 1024
115
+
116
+ export default function KindPreview({
117
+ o, text: textProp, kind: kindProp, urlOf = (x) => x.url, poster, kindOf = defaultKindOf,
118
+ extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT,
119
+ /* ONE RENDERER, THREE FITS (user 2026-09-23: *"theres just a bunch of different views, why? you
120
+ * know what consistency means?"*). Five surfaces each patched this component with their own CSS
121
+ * or bypassed it — the pane, the wall tile, the row thumb, the kind overview and the overlay. The
122
+ * fit says how much room there is; the LADDER below stays one ladder.
123
+ *
124
+ * pane — the column preview and the overlay: captions, full controls
125
+ * tile — a card in the wall or the overview: the render, no prose beside it
126
+ * thumb — a 44px box: no embed, no player; whatever paints instantly or nothing
127
+ * sheet — the body of the Quick Look window: every page of a PDF with a thumbnail rail
128
+ */
129
+ fit = 'pane',
130
+ pdfLimit = PDF_LIMIT,
131
+ }) {
72
132
  /* CONTENT IN HAND, OR A URL TO FETCH (content-card-needs-no-cover,
73
133
  * kol-client-olina 2026-09-04; shape ruled by kol-r2b2, whose component this
74
134
  * is). A markdown note in a D1 row has no file and no URL behind it, so the
@@ -101,7 +161,17 @@ export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf =
101
161
  ? { loading: false, error: null, text: String(textProp).slice(0, textLimit), truncated: String(textProp).length > textLimit }
102
162
  : fetched
103
163
 
164
+ /* NOTHING TO DRAW → THE FILE AS A PAGE (user 2026-09-23). An empty file, audio without artwork
165
+ * and a kind no renderer handles all show the same generic document, sized by the fit. */
166
+ const icon = (
167
+ <FileIcon ext={ext} glyph={KIND_GLYPH[kind]}
168
+ className={fit === 'thumb' ? 'h-full w-auto' : fit === 'tile' ? 'w-[38%] max-w-[160px]' : fit === 'sheet' ? 'w-[200px] m-16' : 'w-[40%] max-w-[180px] my-6'} />
169
+ )
170
+ if (o.size === 0) return <div className="w-full h-full flex items-center justify-center">{icon}</div>
171
+
104
172
  if (kind === 'playlist') {
173
+ /* the caption is pane chrome — in a tile it is a line of prose over the picture */
174
+ if (fit !== 'pane') return <HlsVideo src={url} poster={poster} className="w-full h-full object-cover" />
105
175
  return (
106
176
  <div className="flex flex-col items-center gap-2">
107
177
  <HlsVideo src={url} poster={poster} className="max-w-full max-h-[70vh] rounded" />
@@ -110,7 +180,7 @@ export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf =
110
180
  )
111
181
  }
112
182
  if (kind === 'video') return <VideoTile src={url} poster={poster} />
113
- if (kind === 'audio') return <AudioTile src={url} />
183
+ if (kind === 'audio') return <AudioTile src={url} fallback={icon} />
114
184
  if (isText) {
115
185
  if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
116
186
  if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
@@ -126,13 +196,29 @@ export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf =
126
196
  const language = kind === 'json' ? 'json' : kind === 'yaml' ? 'yaml' : LANG[ext] || 'text'
127
197
  return (
128
198
  <DocPage>
129
- <CodeBlock code={text} language={language} filename={name} />
199
+ <CodeBlock code={text} language={language} filename={name} copy={false} />
130
200
  {truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
131
201
  </DocPage>
132
202
  )
133
203
  }
204
+ /* PDF — drawn by pdf.js at every fit but `thumb` (a 44px box cannot show a page and the
205
+ * download is the same size either way): page one in a tile or the pane, every page with a
206
+ * thumbnail rail in the Quick Look window — Finder's layout. The browser's `<embed>` is gone:
207
+ * it is a viewer, and Chrome paints its toolbar over a tile whatever the URL fragment says.
208
+ * Only for a URL a browser can fetch: a placeholder scheme (a fixture, an unresolved key) gets
209
+ * the icon, not a console error and an empty frame. The size gate stands — the whole file is
210
+ * pulled to paint one page. */
211
+ const servableUrl = /^(https?:|data:|blob:|\/)/.test(String(url ?? ''))
212
+ if (ext === 'pdf' && servableUrl && fit !== 'thumb' && (o.size ?? 0) <= pdfLimit) {
213
+ if (fit === 'sheet') return <PdfDocument src={url} fallback={icon} />
214
+ return (
215
+ <PdfPage src={url} fallback={icon}
216
+ className={fit === 'tile' ? 'w-full h-full object-cover object-top' : 'w-full h-auto rounded'} />
217
+ )
218
+ }
219
+ if (kind === 'font' && servableUrl && fit !== 'thumb') return <FontSpecimen src={url} fit={fit} fallback={icon} />
134
220
  if (kind === 'segments') {
135
221
  return <AssetPlaceholder category="HLS" name={`${o.segmentCount} segments`} note="STREAM CHUNKS" className="w-[420px] max-w-full" />
136
222
  }
137
- return <AssetPlaceholder category={kindLabel[kind] || 'file'} name={name} note={ext ? ext.toUpperCase() : 'FILE'} className="w-[420px] max-w-full" />
223
+ return <div className="w-full h-full flex items-center justify-center">{icon}</div>
138
224
  }