@kolkrabbi/kol-component 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/package.json +11 -3
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/atoms/Button.jsx +17 -6
  5. package/src/atoms/CurveOverlay.jsx +180 -0
  6. package/src/atoms/DocsToc.jsx +48 -0
  7. package/src/atoms/EmptyState.jsx +22 -0
  8. package/src/atoms/Figure.jsx +27 -0
  9. package/src/atoms/HlsVideo.jsx +56 -0
  10. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  11. package/src/atoms/PriceDisplay.jsx +34 -0
  12. package/src/atoms/ProsePreview.jsx +53 -0
  13. package/src/atoms/RotaryDial.jsx +150 -0
  14. package/src/atoms/SearchInput.jsx +108 -0
  15. package/src/atoms/TextPressure.jsx +331 -0
  16. package/src/atoms/TiltCard.jsx +127 -0
  17. package/src/atoms/TypeSample.jsx +50 -0
  18. package/src/atoms/TypeSpecCard.jsx +42 -0
  19. package/src/hooks/cssVar.js +53 -0
  20. package/src/hooks/useAxisAnimation.js +91 -0
  21. package/src/hooks/usePrefersReducedMotion.js +21 -0
  22. package/src/hooks/useTilt.js +49 -0
  23. package/src/index.js +66 -1
  24. package/src/molecules/AlignmentGrid.jsx +53 -0
  25. package/src/molecules/ArticleCard.jsx +178 -0
  26. package/src/molecules/CardFeatureItem.jsx +130 -0
  27. package/src/molecules/ColorInputRow.jsx +179 -0
  28. package/src/molecules/ColorRamp.jsx +114 -0
  29. package/src/molecules/FramedMediaBand.jsx +56 -0
  30. package/src/molecules/ImageBlock.jsx +34 -0
  31. package/src/molecules/SelectionOverlay.jsx +108 -0
  32. package/src/molecules/ShapeDropdown.jsx +92 -0
  33. package/src/molecules/ShellDrawer.jsx +169 -0
  34. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  35. package/src/molecules/SpecList.jsx +30 -0
  36. package/src/molecules/SpectrumControls.jsx +504 -0
  37. package/src/molecules/SwatchControls.jsx +217 -0
  38. package/src/molecules/TabsRow.jsx +87 -0
  39. package/src/molecules/VideoBlock.jsx +86 -0
  40. package/src/molecules/WorkListItem.jsx +83 -0
  41. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  42. package/src/organisms/ArticleHeader.jsx +95 -0
  43. package/src/organisms/AsciiCursor.jsx +526 -0
  44. package/src/organisms/BentoCard.jsx +187 -0
  45. package/src/organisms/Canvas.jsx +299 -0
  46. package/src/organisms/ColorLoader.jsx +155 -0
  47. package/src/organisms/CtaGlobal.jsx +67 -0
  48. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  49. package/src/organisms/EditorShell.jsx +111 -0
  50. package/src/organisms/ErrorBoundary.jsx +70 -0
  51. package/src/organisms/FeatureSplit.jsx +82 -0
  52. package/src/organisms/FeaturedCarousel.jsx +258 -0
  53. package/src/organisms/FeaturesCardSection.jsx +90 -0
  54. package/src/organisms/FullBleedHero.jsx +111 -0
  55. package/src/organisms/GalleryCarousel.jsx +83 -0
  56. package/src/organisms/LoaderOverlay.jsx +30 -0
  57. package/src/organisms/MediaViewer.jsx +95 -0
  58. package/src/organisms/NewsletterBand.jsx +122 -0
  59. package/src/organisms/ParallaxShelf.jsx +141 -0
  60. package/src/organisms/PortableTextRenderer.jsx +115 -0
  61. package/src/organisms/ProductDetailLayout.jsx +189 -0
  62. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  63. package/src/organisms/SpectrumGrid.jsx +90 -0
  64. package/src/organisms/StackHero.jsx +83 -0
  65. package/src/organisms/WorkCard.jsx +120 -0
  66. package/src/organisms/WorkViewToggle.jsx +170 -0
  67. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  68. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  69. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  70. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  71. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  72. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  73. package/src/organisms/foundry/glyphData.js +30 -0
  74. package/src/organisms/foundry/index.js +21 -0
@@ -0,0 +1,34 @@
1
+ import Figure from '../atoms/Figure.jsx'
2
+ import Image from './Image.jsx'
3
+
4
+ /**
5
+ * ImageBlock — a captioned prose image: the DS Figure shell (optional label,
6
+ * aspect-locked bordered frame, optional figcaption) wrapping a cover-fit DS
7
+ * Image. The long-form counterpart to VideoBlock — both compose the same
8
+ * Figure atom, so the frame chrome lives in one place.
9
+ *
10
+ * De-Sanitized: takes a resolved `src` string (no CMS `value` object, no
11
+ * SanityImage URL builder). Missing `src` degrades to Image's own
12
+ * AssetPlaceholder rather than rendering an empty frame.
13
+ *
14
+ * Label and caption render exactly as authored — no casing transforms.
15
+ *
16
+ * @param {string} src resolved image URL
17
+ * @param {string} alt alt text
18
+ * @param {string} label small mono label above the frame
19
+ * @param {string} caption figcaption below the frame
20
+ * @param {string} aspect CSS aspect-ratio for the frame (default '5/3')
21
+ * @param {string} className extra classes on the <figure>
22
+ */
23
+ export default function ImageBlock({ src, alt = '', label, caption, aspect = '5/3', className = '' }) {
24
+ return (
25
+ <Figure label={label} caption={caption} aspect={aspect} className={className}>
26
+ <Image
27
+ src={src}
28
+ alt={alt}
29
+ className="object-cover"
30
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
31
+ />
32
+ </Figure>
33
+ )
34
+ }
@@ -0,0 +1,108 @@
1
+ /* taxonomy-ok: presentational transform-chrome overlay. It nests no KOL
2
+ * component (pure inline-styled squares + label), so by the letter of the
3
+ * molecule test it reads as an atom — but the lobby spec places it as a
4
+ * molecule: a reusable compound bounding-box/handles primitive that pairs with
5
+ * the Canvas scale layer, not a base atom. Kept here per that spec. */
6
+
7
+ /**
8
+ * SelectionOverlay — pure transform chrome for a selected box.
9
+ *
10
+ * Renders a dashed outline, 8 named resize handles, and a `W × H` dimension
11
+ * label, all positioned in the **same 1080-virtual coordinate space** the
12
+ * target lives in (pairs with Canvas's scale layer — place it as a sibling of
13
+ * the box inside the same scale layer). Each handle carries a
14
+ * `data-handle="NW|N|NE|E|SE|S|SW|W"` attribute so a parent's pointer router
15
+ * can start the right resize mode. No interaction logic of its own — the drag
16
+ * math lives in the consumer, which reads `e.target.dataset.handle`.
17
+ *
18
+ * Ported from the brand editor with the `layer` model reduced to a flat `box`
19
+ * (per lobby spec): renders nothing when there's no positional box.
20
+ *
21
+ * @param {{x:number,y:number,w:number,h:number}} box virtual-coord position + size; null/x==null → renders nothing
22
+ * @param {boolean} showHandles render the 8 resize handles (default true)
23
+ * @param {boolean} showLabel render the `W × H` dimension label (default true)
24
+ * @param {number} handleSize handle square size in virtual px (default 10)
25
+ * @param {string} accentColor outline + handle + label color (default var(--kol-accent-primary))
26
+ * @param {Function} labelFormatter (box) => string — dimension readout (default `${round(w)} × ${round(h)}`)
27
+ */
28
+ const HANDLE_DIRS = [
29
+ { dir: 'NW', cursor: 'nwse-resize', x: 0, y: 0 },
30
+ { dir: 'N', cursor: 'ns-resize', x: 0.5, y: 0 },
31
+ { dir: 'NE', cursor: 'nesw-resize', x: 1, y: 0 },
32
+ { dir: 'E', cursor: 'ew-resize', x: 1, y: 0.5 },
33
+ { dir: 'SE', cursor: 'nwse-resize', x: 1, y: 1 },
34
+ { dir: 'S', cursor: 'ns-resize', x: 0.5, y: 1 },
35
+ { dir: 'SW', cursor: 'nesw-resize', x: 0, y: 1 },
36
+ { dir: 'W', cursor: 'ew-resize', x: 0, y: 0.5 },
37
+ ]
38
+
39
+ export default function SelectionOverlay({
40
+ box,
41
+ showHandles = true,
42
+ showLabel = true,
43
+ handleSize = 10,
44
+ accentColor = 'var(--kol-accent-primary)',
45
+ labelFormatter = (b) => `${Math.round(b.w)} × ${Math.round(b.h)}`,
46
+ }) {
47
+ if (!box || box.x == null) return null /* no positional box → no chrome */
48
+
49
+ const { x, y, w, h } = box
50
+
51
+ return (
52
+ <div
53
+ style={{
54
+ position: 'absolute',
55
+ left: x, top: y,
56
+ width: w, height: h,
57
+ pointerEvents: 'none',
58
+ zIndex: 100,
59
+ }}
60
+ >
61
+ <div
62
+ style={{
63
+ position: 'absolute', inset: 0,
64
+ outline: `1px dashed ${accentColor}`,
65
+ outlineOffset: 0,
66
+ }}
67
+ />
68
+ {showHandles && HANDLE_DIRS.map(({ dir, cursor, x: hx, y: hy }) => (
69
+ <div
70
+ key={dir}
71
+ data-handle={dir}
72
+ style={{
73
+ position: 'absolute',
74
+ left: `calc(${hx * 100}% - ${handleSize / 2}px)`,
75
+ top: `calc(${hy * 100}% - ${handleSize / 2}px)`,
76
+ width: handleSize,
77
+ height: handleSize,
78
+ background: 'white',
79
+ border: `1px solid ${accentColor}`,
80
+ cursor,
81
+ pointerEvents: 'auto',
82
+ }}
83
+ />
84
+ ))}
85
+ {showLabel && (
86
+ <span
87
+ style={{
88
+ position: 'absolute',
89
+ left: 0,
90
+ top: '100%',
91
+ marginTop: 6,
92
+ fontFamily: 'var(--kol-font-family-mono)',
93
+ fontSize: 10,
94
+ letterSpacing: '0.04em',
95
+ color: accentColor,
96
+ background: 'rgba(0,0,0,0.6)',
97
+ padding: '2px 6px',
98
+ borderRadius: 2,
99
+ whiteSpace: 'nowrap',
100
+ pointerEvents: 'none',
101
+ }}
102
+ >
103
+ {labelFormatter(box)}
104
+ </span>
105
+ )}
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,92 @@
1
+ import { useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-loader'
3
+ import Button from '../atoms/Button.jsx'
4
+ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
5
+ import { MenuDropdownItem } from './MenuItem.jsx'
6
+
7
+ /**
8
+ * ShapeDropdown — split icon-button + variant-menu molecule (the tool-palette
9
+ * idiom: Select · Text · [Shape ▾] · Pattern). The main button reflects the
10
+ * current variant and fires `onAction` with its id; the chevron half opens a
11
+ * menu of all variants — picking one fires `onChange` and closes.
12
+ *
13
+ * Composed from Button (both trigger halves), usePopover/PopoverPanel (menu
14
+ * positioning, dismiss, portal) and MenuDropdownItem (rows — same rows as the
15
+ * Dropdown molecule, ✓ marks the active variant).
16
+ *
17
+ * For single-value list selection with a text trigger use `Dropdown`; this is
18
+ * for tool bars where the trigger is itself an action.
19
+ *
20
+ * @param {Object} props
21
+ * @param {{id: string, label: string, icon?: string}[]} props.options - Variants: menu rows + trigger glyph. `icon` optional — the trigger falls back to the label.
22
+ * @param {string} props.value - Active variant id (controlled)
23
+ * @param {Function} props.onChange - Fires with the picked variant id (menu selection)
24
+ * @param {Function} props.onAction - Fires with the current variant id (main-button click)
25
+ * @param {string} props.className - Additional classes on the wrapper
26
+ */
27
+ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = '' }) => {
28
+ const [open, setOpen] = useState(false)
29
+ const popover = usePopover({
30
+ open,
31
+ onOpenChange: setOpen,
32
+ placement: 'bottom-start',
33
+ offset: 4,
34
+ role: 'menu',
35
+ })
36
+
37
+ const current = options.find((option) => option.id === value) || options[0]
38
+
39
+ const handleSelect = (option) => {
40
+ onChange?.(option.id)
41
+ setOpen(false)
42
+ }
43
+
44
+ return (
45
+ <div className={`inline-flex items-center ${className}`.trim()}>
46
+ {current?.icon ? (
47
+ <Button
48
+ variant="ghost"
49
+ size="sm"
50
+ quiet
51
+ iconOnly={current.icon}
52
+ aria-label={current.label}
53
+ title={current.label}
54
+ onClick={() => onAction?.(current.id)}
55
+ />
56
+ ) : (
57
+ <Button variant="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
58
+ {current?.label}
59
+ </Button>
60
+ )}
61
+ {/* Button doesn't forward refs — anchor the popover on a span wrapper,
62
+ * same pattern as Tooltip in atoms/Popover.jsx. Clicks on the inner
63
+ * button bubble to the span, where useClick toggles the menu. */}
64
+ <span
65
+ ref={popover.refs.setReference}
66
+ {...popover.getReferenceProps()}
67
+ className="inline-flex"
68
+ >
69
+ <Button variant="ghost" size="sm" quiet iconOnly="chevron-down" iconSize={10} aria-label="Variants" />
70
+ </span>
71
+ <PopoverPanel
72
+ popover={popover}
73
+ panel={false}
74
+ focus={false}
75
+ className="bg-surface-secondary border border-fg-08 rounded shadow-lg"
76
+ >
77
+ {options.map((option) => (
78
+ <MenuDropdownItem
79
+ key={option.id}
80
+ onClick={() => handleSelect(option)}
81
+ iconLeft={option.icon ? <Icon name={option.icon} size={14} /> : undefined}
82
+ shortcut={option.id === current?.id ? <Icon name="check" size={11} /> : undefined}
83
+ >
84
+ {option.label}
85
+ </MenuDropdownItem>
86
+ ))}
87
+ </PopoverPanel>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ export default ShapeDropdown
@@ -0,0 +1,169 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Icon } from '@kolkrabbi/kol-loader'
4
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
5
+
6
+ /* taxonomy-ok: nests kol-loader's Icon (a package import the relative-import
7
+ * check can't see). */
8
+
9
+ const FOCUSABLE =
10
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
11
+
12
+ /**
13
+ * ShellDrawer — THE edge drawer: a portalled panel that slides in from the
14
+ * left or right viewport edge over a dimming backdrop. Distinct from Modal
15
+ * (centered prompt/confirm) and FullscreenOverlay (fills the whole viewport,
16
+ * not an edge sheet). Escape, backdrop click and the built-in close button
17
+ * all call `onClose`; body scroll locks while open; focus moves into the
18
+ * panel on open, is trapped there, and returns to the opener on close.
19
+ *
20
+ * The slide transition is gated on prefers-reduced-motion: reduced users get
21
+ * an instant mount/unmount with no transform animation.
22
+ *
23
+ * Presentational shell — the parent owns the open state and supplies the
24
+ * header slot and body; no navigation or routing logic lives here.
25
+ *
26
+ * @param {boolean} open drawer visible (drives slide in/out)
27
+ * @param {Function} onClose close request (Esc / backdrop / close button)
28
+ * @param {string} side 'left' | 'right' — edge the panel slides from
29
+ * @param {number|string} width panel width (px number or CSS length); omit for full-width sheet
30
+ * @param {ReactNode} header header-row content beside the close button (replaces the source's baked-in wordmark)
31
+ * @param {ReactNode} children scrollable panel body
32
+ * @param {string} className extra classes on the panel
33
+ */
34
+ export default function ShellDrawer({
35
+ open,
36
+ onClose,
37
+ side = 'left',
38
+ width,
39
+ header,
40
+ children,
41
+ className = '',
42
+ }) {
43
+ const reduced = usePrefersReducedMotion()
44
+ const panelRef = useRef(null)
45
+
46
+ /* Mount/slide state machine: `present` keeps the portal mounted through
47
+ * the exit slide; `shown` drives the transform/opacity classes. Reduced
48
+ * motion collapses both onto `open` — instant show/hide, no transform. */
49
+ const [present, setPresent] = useState(open)
50
+ const [shown, setShown] = useState(open)
51
+
52
+ useEffect(() => {
53
+ if (reduced) {
54
+ setPresent(open)
55
+ setShown(open)
56
+ return undefined
57
+ }
58
+ if (open) {
59
+ setPresent(true)
60
+ // double rAF: let the off-screen position paint before sliding in
61
+ let raf2
62
+ const raf1 = requestAnimationFrame(() => {
63
+ raf2 = requestAnimationFrame(() => setShown(true))
64
+ })
65
+ return () => {
66
+ cancelAnimationFrame(raf1)
67
+ if (raf2) cancelAnimationFrame(raf2)
68
+ }
69
+ }
70
+ setShown(false)
71
+ const t = setTimeout(() => setPresent(false), 200) // matches duration-200
72
+ return () => clearTimeout(t)
73
+ }, [open, reduced])
74
+
75
+ // Escape closes; Tab is trapped inside the panel while open
76
+ useEffect(() => {
77
+ if (!open) return undefined
78
+ const onKey = (e) => {
79
+ if (e.key === 'Escape') {
80
+ onClose?.()
81
+ return
82
+ }
83
+ if (e.key !== 'Tab') return
84
+ const panel = panelRef.current
85
+ if (!panel) return
86
+ const nodes = panel.querySelectorAll(FOCUSABLE)
87
+ if (!nodes.length) {
88
+ e.preventDefault()
89
+ panel.focus()
90
+ return
91
+ }
92
+ const first = nodes[0]
93
+ const last = nodes[nodes.length - 1]
94
+ const active = document.activeElement
95
+ if (!panel.contains(active)) {
96
+ e.preventDefault()
97
+ first.focus()
98
+ } else if (e.shiftKey && (active === first || active === panel)) {
99
+ e.preventDefault()
100
+ last.focus()
101
+ } else if (!e.shiftKey && active === last) {
102
+ e.preventDefault()
103
+ first.focus()
104
+ }
105
+ }
106
+ document.addEventListener('keydown', onKey)
107
+ return () => document.removeEventListener('keydown', onKey)
108
+ }, [open, onClose])
109
+
110
+ /* Body scroll-lock + focus in/out. Gated on `present` too so the panel
111
+ * exists before we focus it (it mounts one commit after `open` flips). */
112
+ useEffect(() => {
113
+ if (!(open && present)) return undefined
114
+ const prevOverflow = document.body.style.overflow
115
+ document.body.style.overflow = 'hidden'
116
+ const prevFocus = document.activeElement
117
+ panelRef.current?.focus()
118
+ return () => {
119
+ document.body.style.overflow = prevOverflow
120
+ if (prevFocus instanceof HTMLElement) prevFocus.focus()
121
+ }
122
+ }, [open, present])
123
+
124
+ if (!present || typeof document === 'undefined') return null
125
+
126
+ const slideOut = side === 'right' ? 'translate-x-full' : '-translate-x-full'
127
+ const motionPanel = reduced
128
+ ? ''
129
+ : `transition-transform duration-200 ease-out ${shown ? 'translate-x-0' : slideOut}`
130
+ const motionBackdrop = reduced
131
+ ? ''
132
+ : `transition-opacity duration-200 ease-out ${shown ? 'opacity-100' : 'opacity-0'}`
133
+
134
+ return createPortal(
135
+ <>
136
+ <div
137
+ className={`fixed inset-0 z-[100] bg-black/50 ${motionBackdrop}`}
138
+ onClick={onClose}
139
+ aria-hidden="true"
140
+ />
141
+ <div
142
+ ref={panelRef}
143
+ role="dialog"
144
+ aria-modal="true"
145
+ tabIndex={-1}
146
+ className={`fixed inset-y-0 z-[200] flex max-w-full flex-col bg-surface-primary px-4 py-4 shadow-2xl outline-none md:px-5 lg:px-6 ${
147
+ side === 'right' ? 'right-0 border-l' : 'left-0 border-r'
148
+ } border-fg-08 ${width == null ? 'w-full' : ''} ${motionPanel} ${className}`}
149
+ style={width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
150
+ >
151
+ <div className="mb-6 flex items-center gap-4">
152
+ {header != null && <div className="min-w-0 flex-1">{header}</div>}
153
+ <button
154
+ type="button"
155
+ onClick={onClose}
156
+ aria-label="Close"
157
+ className="ml-auto flex h-8 w-8 shrink-0 items-center justify-center rounded-md border-0 bg-transparent cursor-pointer text-fg-64 transition-colors hover:bg-fg-08 hover:text-emphasis"
158
+ >
159
+ <Icon name="close" size={14} />
160
+ </button>
161
+ </div>
162
+ <div className="flex-1 overflow-y-auto pr-1" style={{ overflowAnchor: 'none' }}>
163
+ {children}
164
+ </div>
165
+ </div>
166
+ </>,
167
+ document.body,
168
+ )
169
+ }
@@ -0,0 +1,177 @@
1
+ import { useEffect, useId, useRef, useState } from 'react'
2
+ import SearchInput from '../atoms/SearchInput.jsx'
3
+
4
+ /**
5
+ * HighlightMatch — default row renderer: underlines the first
6
+ * case-insensitive `query` slice inside `label` at full ink. Exported for
7
+ * consumers building their own rows; not in the package barrel.
8
+ *
9
+ * @param {string} label full row label
10
+ * @param {string} query current query (empty / no match → plain label)
11
+ */
12
+ export function HighlightMatch({ label, query }) {
13
+ const idx = query ? label.toLowerCase().indexOf(query.toLowerCase()) : -1
14
+ if (idx === -1) return <span>{label}</span>
15
+ return (
16
+ <>
17
+ <span>{label.slice(0, idx)}</span>
18
+ <span className="text-fg underline decoration-2 underline-offset-[3px]">
19
+ {label.slice(idx, idx + query.length)}
20
+ </span>
21
+ <span>{label.slice(idx + query.length)}</span>
22
+ </>
23
+ )
24
+ }
25
+
26
+ /**
27
+ * ShellSearchOverlay — the ⌘K command palette: fullscreen dim + centered
28
+ * panel, a bare SearchInput on top, result rows beneath (HighlightMatch
29
+ * label, dim hint line, right-aligned group label). Distinct from Modal
30
+ * (prompt/confirm only) — this is the search/command primitive.
31
+ *
32
+ * Content-agnostic: the consumer filters and passes `results`; selection
33
+ * emits `onSelect(item)` (no navigation here — ported off react-router).
34
+ * The ⌘K binding itself lives in the shell's key handler, not here.
35
+ *
36
+ * Keyboard: ArrowUp/ArrowDown rove the active row (mouse hover roves too),
37
+ * Enter selects it (index starts at 0 → Enter-selects-first preserved),
38
+ * Escape closes. Focus trap: focus moves into the input on open, returns to
39
+ * the opener on close, and Tab is pinned — rows are combobox options driven
40
+ * via aria-activedescendant, never tab stops.
41
+ *
42
+ * @param {boolean} open mount/unmount the overlay
43
+ * @param {Function} onClose () => void — backdrop click, Escape, post-select
44
+ * @param {Array} results pre-filtered rows: { id, label, group?, hint? }
45
+ * @param {string} query controlled query (drives the highlight slice)
46
+ * @param {Function} onQueryChange (string) => void — input change
47
+ * @param {Function} onSelect (item) => void — row click / Enter; consumer navigates
48
+ * @param {string} placeholder input placeholder
49
+ */
50
+ export default function ShellSearchOverlay({
51
+ open,
52
+ onClose,
53
+ results = [],
54
+ query = '',
55
+ onQueryChange,
56
+ onSelect,
57
+ placeholder = 'Search…',
58
+ }) {
59
+ const panelRef = useRef(null)
60
+ const listRef = useRef(null)
61
+ const listId = useId()
62
+ const [activeIndex, setActiveIndex] = useState(0)
63
+ const active = results.length > 0 ? Math.min(activeIndex, results.length - 1) : -1
64
+
65
+ /* Focus in on open, restore the opener on close. querySelector instead of
66
+ * a ref through SearchInput — ref-as-prop needs React 19 and the package
67
+ * peer range still allows 18. */
68
+ useEffect(() => {
69
+ if (!open) return undefined
70
+ const prev = document.activeElement
71
+ panelRef.current?.querySelector('input')?.focus()
72
+ return () => { if (prev instanceof HTMLElement) prev.focus() }
73
+ }, [open])
74
+
75
+ /* Roving row resets to the top on every query change / reopen. */
76
+ useEffect(() => { setActiveIndex(0) }, [query, open])
77
+
78
+ /* Keep the active row visible inside the scrolling list. */
79
+ useEffect(() => {
80
+ if (active < 0) return
81
+ listRef.current?.children[active]?.scrollIntoView({ block: 'nearest' })
82
+ }, [active])
83
+
84
+ if (!open) return null
85
+
86
+ const select = (item) => {
87
+ onSelect?.(item)
88
+ onClose?.()
89
+ }
90
+
91
+ const optionId = (item) => `${listId}-${item.id}`
92
+
93
+ const handleKeyDown = (e) => {
94
+ if (e.key === 'Escape') {
95
+ e.preventDefault()
96
+ onClose?.()
97
+ } else if (e.key === 'ArrowDown') {
98
+ e.preventDefault()
99
+ setActiveIndex((i) => Math.min(i + 1, results.length - 1))
100
+ } else if (e.key === 'ArrowUp') {
101
+ e.preventDefault()
102
+ setActiveIndex((i) => Math.max(i - 1, 0))
103
+ } else if (e.key === 'Enter' && active >= 0) {
104
+ e.preventDefault()
105
+ select(results[active])
106
+ } else if (e.key === 'Tab') {
107
+ /* Focus trap — the input is the palette's only tab stop. */
108
+ e.preventDefault()
109
+ }
110
+ }
111
+
112
+ return (
113
+ <div className="fixed inset-0 z-[300] flex items-start justify-center pt-[20vh]">
114
+ <div
115
+ className="absolute inset-0 bg-black/60 backdrop-blur-[1px]"
116
+ onClick={onClose}
117
+ aria-hidden="true"
118
+ />
119
+ <div
120
+ ref={panelRef}
121
+ role="dialog"
122
+ aria-modal="true"
123
+ aria-label="Search"
124
+ className="relative w-full max-w-lg mx-4 overflow-hidden bg-surface-primary border border-fg-08 rounded-[var(--kol-radius-2xl)] shadow-[0_20px_60px_rgba(0,0,0,0.4)]"
125
+ >
126
+ <SearchInput
127
+ bare
128
+ value={query}
129
+ onChange={(e) => onQueryChange?.(e.target.value)}
130
+ placeholder={placeholder}
131
+ onKeyDown={handleKeyDown}
132
+ role="combobox"
133
+ aria-expanded={results.length > 0}
134
+ aria-controls={listId}
135
+ aria-activedescendant={active >= 0 ? optionId(results[active]) : undefined}
136
+ />
137
+
138
+ {results.length > 0 && (
139
+ <ul
140
+ ref={listRef}
141
+ id={listId}
142
+ role="listbox"
143
+ className="border-t border-fg-08 max-h-80 overflow-y-auto py-1"
144
+ >
145
+ {results.map((item, i) => (
146
+ <li
147
+ key={item.id}
148
+ id={optionId(item)}
149
+ role="option"
150
+ aria-selected={i === active}
151
+ /* preventDefault keeps focus in the input through the click */
152
+ onMouseDown={(e) => e.preventDefault()}
153
+ onClick={() => select(item)}
154
+ onMouseEnter={() => setActiveIndex(i)}
155
+ className={`flex items-center gap-2 px-4 py-1.5 cursor-pointer kol-mono-14 transition-colors ${
156
+ i === active ? 'bg-fg-08 text-fg' : 'text-fg-64'
157
+ }`}
158
+ >
159
+ <span className="flex flex-col min-w-0">
160
+ <span className="truncate">
161
+ <HighlightMatch label={item.label} query={query} />
162
+ </span>
163
+ {item.hint && (
164
+ <span className="kol-mono-12 text-fg-48 truncate">{item.hint}</span>
165
+ )}
166
+ </span>
167
+ {item.group && (
168
+ <span className="ml-auto shrink-0 kol-helper-10 text-fg-48">{item.group}</span>
169
+ )}
170
+ </li>
171
+ ))}
172
+ </ul>
173
+ )}
174
+ </div>
175
+ </div>
176
+ )
177
+ }
@@ -0,0 +1,30 @@
1
+ import Divider from '../atoms/Divider.jsx'
2
+
3
+ /**
4
+ * SpecList — compact definition list of [label | value] rows: label left and
5
+ * muted, value right-aligned one tone brighter, Divider-separated between
6
+ * rows (never after the last). Data-agnostic "key facts" strip — the caller
7
+ * formats values before passing (e.g. "Limited (30)", "A3, A2, A1").
8
+ *
9
+ * @param {Array<{label: string, value: ReactNode}>} items rows, keyed by label
10
+ * @param {boolean} framed wrap the list in leading + trailing Divider
11
+ */
12
+ export default function SpecList({ items = [], framed = false }) {
13
+ return (
14
+ <>
15
+ {framed && <Divider />}
16
+ <dl className="py-3">
17
+ {items.map((item, index) => (
18
+ <div key={item.label}>
19
+ <div className="flex items-center justify-between gap-6 py-3">
20
+ <dt className="kol-helper-12 text-fg-48 whitespace-nowrap">{item.label}</dt>
21
+ <dd className="kol-mono-12 text-right text-fg-64">{item.value}</dd>
22
+ </div>
23
+ {index < items.length - 1 && <Divider />}
24
+ </div>
25
+ ))}
26
+ </dl>
27
+ {framed && <Divider />}
28
+ </>
29
+ )
30
+ }