@kolkrabbi/kol-component 0.1.1 → 0.2.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 (39) hide show
  1. package/package.json +2 -2
  2. package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
  3. package/src/atoms/Avatar.jsx +4 -4
  4. package/src/atoms/Button.jsx +6 -3
  5. package/src/atoms/CopyButton.jsx +50 -0
  6. package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
  7. package/src/atoms/Input.jsx +7 -2
  8. package/src/{molecules → atoms}/SegmentedToggle.jsx +33 -14
  9. package/src/atoms/ToggleCheckbox.jsx +1 -1
  10. package/src/atoms/ToggleSwitch.jsx +1 -1
  11. package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
  12. package/src/graphics/Graphic.jsx +56 -18
  13. package/src/graphics/graphicData.js +19 -0
  14. package/src/index.js +54 -39
  15. package/src/{primitives → molecules}/Accordion.jsx +4 -1
  16. package/src/molecules/CodeBlock.jsx +11 -0
  17. package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
  18. package/src/molecules/Dropdown.jsx +3 -2
  19. package/src/{primitives → molecules}/Image.jsx +1 -1
  20. package/src/molecules/MediaCard.jsx +97 -0
  21. package/src/molecules/MediaRow.jsx +49 -0
  22. package/src/molecules/MenuItem.jsx +3 -2
  23. package/src/molecules/MenuPopover.jsx +11 -96
  24. package/src/{atoms → molecules}/Slider.jsx +1 -1
  25. package/src/{molecules → organisms}/ContentFilters.jsx +2 -2
  26. package/src/primitives/CodeBlock.jsx +0 -41
  27. /package/src/{molecules → atoms}/Badge.jsx +0 -0
  28. /package/src/{primitives → atoms}/ExitPreview.jsx +0 -0
  29. /package/src/{primitives → atoms}/FullscreenOverlay.jsx +0 -0
  30. /package/src/{molecules → atoms}/LabeledControl.jsx +0 -0
  31. /package/src/{molecules → atoms}/Pill.jsx +0 -0
  32. /package/src/{molecules → atoms}/Popover.jsx +0 -0
  33. /package/src/{molecules → atoms}/QuantityInput.jsx +0 -0
  34. /package/src/{molecules → atoms}/QuantityStepper.jsx +0 -0
  35. /package/src/{molecules → atoms}/Section.jsx +0 -0
  36. /package/src/{molecules → atoms}/SectionLabel.jsx +0 -0
  37. /package/src/{molecules → atoms}/Tag.jsx +0 -0
  38. /package/src/{molecules → atoms}/ToggleBracket.jsx +0 -0
  39. /package/src/{primitives → organisms}/Carousel.jsx +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -23,7 +23,7 @@
23
23
  "dependencies": {
24
24
  "@floating-ui/react": "^0.27.19",
25
25
  "embla-carousel-react": "^8.6.0",
26
- "@kolkrabbi/kol-loader": "0.1.1"
26
+ "@kolkrabbi/kol-loader": "0.3.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "react": "^18.3.0 || ^19.0.0",
@@ -21,8 +21,10 @@ export default function AssetPlaceholder({
21
21
  role="img"
22
22
  aria-label={`${label || 'asset'} — ${note}`}
23
23
  >
24
- <span className="kol-asset-placeholder-note text-[12px] uppercase [letter-spacing:0.12em] text-fg-48">{note}</span>
25
- {label && <span className="kol-asset-placeholder-label text-[12px] text-fg-40 [letter-spacing:0.04em]">{label}</span>}
24
+ {/* note is single-line helper scale; label can wrap (category · name
25
+ * in a narrow tile) → line-height-bearing mono scale */}
26
+ <span className="kol-asset-placeholder-note kol-helper-12 uppercase [letter-spacing:0.12em] text-fg-48">{note}</span>
27
+ {label && <span className="kol-asset-placeholder-label kol-mono-12 text-fg-40 [letter-spacing:0.04em]">{label}</span>}
26
28
  </div>
27
29
  )
28
30
  }
@@ -1,8 +1,8 @@
1
1
  const SIZE_MAP = {
2
- sm: 'w-8 h-8 text-xs',
3
- md: 'w-10 h-10 text-sm',
4
- lg: 'w-14 h-14 text-base',
5
- xl: 'w-24 h-24 text-3xl',
2
+ sm: 'w-8 h-8 kol-helper-12',
3
+ md: 'w-10 h-10 kol-helper-14',
4
+ lg: 'w-14 h-14 kol-helper-16',
5
+ xl: 'w-24 h-24 kol-helper-20',
6
6
  }
7
7
 
8
8
  export default function Avatar({ initial, size = 'sm', className = '' }) {
@@ -117,13 +117,16 @@ const Button = ({
117
117
  return renderIcon(iconOnly, iconOnlyHover)
118
118
  }
119
119
 
120
- // Button with icon(s) and text
120
+ // Button with icon(s) and text. Icons render directly as flex items —
121
+ // wrapper spans (with the old -2px optical margins) sat the glyphs on
122
+ // the span's text baseline instead of letting align-items center them
123
+ // against the label.
121
124
  if (iconLeft || iconRight || iconLeftHover || iconRightHover) {
122
125
  return (
123
126
  <span className="flex items-center" style={{ gap: iconGap ?? 8 }}>
124
- {(iconLeft || iconLeftHover) && <span style={{ marginLeft: -2 }}>{renderIcon(iconLeft, iconLeftHover)}</span>}
127
+ {(iconLeft || iconLeftHover) && renderIcon(iconLeft, iconLeftHover)}
125
128
  {children}
126
- {(iconRight || iconRightHover) && <span style={{ marginRight: -2 }}>{renderIcon(iconRight, iconRightHover)}</span>}
129
+ {(iconRight || iconRightHover) && renderIcon(iconRight, iconRightHover)}
127
130
  </span>
128
131
  )
129
132
  }
@@ -0,0 +1,50 @@
1
+ import { useState } from 'react'
2
+
3
+ /**
4
+ * CopyButton — copy-to-clipboard chip. Clipboard icon + Copy/Copied label
5
+ * swap with a 1.8s reset; silent when the clipboard is blocked. Chrome
6
+ * comes from .kol-copy-btn (kol-theme); parents add their own positioning
7
+ * class (e.g. CodeBlock's .kol-codeblock-copy).
8
+ *
9
+ * Props:
10
+ * text — string (or () => string) written to the clipboard
11
+ * label — show the Copy/Copied text next to the icon (default true;
12
+ * false = icon-only for tight spots)
13
+ * className — extra classes (positioning etc.)
14
+ */
15
+ export default function CopyButton({ text, label = true, className = '', ...props }) {
16
+ const [copied, setCopied] = useState(false)
17
+
18
+ const onCopy = async () => {
19
+ try {
20
+ await navigator.clipboard.writeText(typeof text === 'function' ? text() : String(text ?? ''))
21
+ setCopied(true)
22
+ setTimeout(() => setCopied(false), 1800)
23
+ } catch {
24
+ /* clipboard blocked — silent */
25
+ }
26
+ }
27
+
28
+ return (
29
+ <button
30
+ type="button"
31
+ className={`kol-copy-btn ${className}`.trim()}
32
+ onClick={onCopy}
33
+ aria-label={copied ? 'Copied' : 'Copy to clipboard'}
34
+ title={copied ? 'Copied' : 'Copy'}
35
+ {...props}
36
+ >
37
+ {copied ? (
38
+ <svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
39
+ <path d="M5 10 L9 14 L15 6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
40
+ </svg>
41
+ ) : (
42
+ <svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
43
+ <rect x="6" y="3" width="10" height="12" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4" />
44
+ <rect x="4" y="6" width="10" height="12" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4" />
45
+ </svg>
46
+ )}
47
+ {label && <span>{copied ? 'Copied' : 'Copy'}</span>}
48
+ </button>
49
+ )
50
+ }
@@ -16,9 +16,10 @@ const DropdownTagFilter = ({
16
16
  selectedValues = new Set(),
17
17
  onChange,
18
18
  className = '',
19
+ defaultOpen = false,
19
20
  size
20
21
  }) => {
21
- const [isOpen, setIsOpen] = useState(false)
22
+ const [isOpen, setIsOpen] = useState(defaultOpen)
22
23
  const dropdownRef = useRef(null)
23
24
  const [resolvedSize, setResolvedSize] = useState('md')
24
25
 
@@ -102,10 +102,15 @@ export default function Input({
102
102
  {prefix !== undefined && (
103
103
  <span aria-hidden="true" className="text-meta pr-1 shrink-0">{prefix}</span>
104
104
  )}
105
+ {/* Controlled only when a `value` prop is passed — otherwise stay
106
+ * uncontrolled so prop-less usages (search stubs, quick demos) type
107
+ * normally instead of freezing on a value-without-onChange input.
108
+ * Controlled + no onChange = deliberate display-only → readOnly. */}
105
109
  <input
106
110
  type={type}
107
- value={value ?? ''}
108
- onChange={onChange}
111
+ {...(value !== undefined
112
+ ? { value: value ?? '', onChange, readOnly: !onChange || undefined }
113
+ : { onChange })}
109
114
  placeholder={placeholder}
110
115
  disabled={disabled}
111
116
  spellCheck={false}
@@ -1,7 +1,9 @@
1
1
  /**
2
- * SegmentedToggle — N-way segmented control. Joined buttons sharing one
3
- * outer stroke; thin dividers between cells; no gap. Active cell uses
4
- * bg-fg-04 + text-emphasis, inactive is text-meta with hover lifting.
2
+ * SegmentedToggle — N-way segmented control. Joined cells sharing one
3
+ * outer stroke; thin dividers between cells; no gap. Active cell fills
4
+ * with surface-secondary + text-emphasis, inactive is text-meta with
5
+ * hover lifting. Chrome comes from .kol-seg* in kol-theme (NOT Tailwind
6
+ * utilities — those never generate from package sources).
5
7
  *
6
8
  * <SegmentedToggle
7
9
  * value={current}
@@ -17,6 +19,9 @@
17
19
  * Labels accept any node — pass strings or inline SVG previews. Optional
18
20
  * `ariaLabel` per option for non-text labels.
19
21
  *
22
+ * A11y: radiogroup/radio semantics with a roving tabindex — Tab enters
23
+ * the group on the active cell, ←/→ (or ↑/↓) move selection + focus.
24
+ *
20
25
  * Props:
21
26
  * value — current option value
22
27
  * onChange — handler (newValue) => void
@@ -25,27 +30,41 @@
25
30
  * only options (e.g. line-style previews) where text labels
26
31
  * aren't used; the cells still center their contents but the
27
32
  * outer height is tighter.
33
+ * ariaLabel — accessible name for the group
28
34
  * className — additional classes on the outer shell
29
35
  */
30
- export default function SegmentedToggle({ value, onChange, options = [], size = 'md', className = '' }) {
31
- const wrapHeight = size === 'sm' ? 'h-4' : 'h-[26px]' /* 16 vs 26 */
32
- const cellType = size === 'sm' ? '' : 'kol-mono-12'
36
+ export default function SegmentedToggle({ value, onChange, options = [], size = 'md', ariaLabel, className = '' }) {
37
+ const cellType = size === 'sm' ? '' : 'kol-mono-12'
38
+ const focusIdx = Math.max(0, options.findIndex((opt) => opt.value === value))
39
+
40
+ const handleKeyDown = (e) => {
41
+ const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key]
42
+ if (!dir || !options.length) return
43
+ e.preventDefault()
44
+ const next = (focusIdx + dir + options.length) % options.length
45
+ onChange?.(options[next].value)
46
+ e.currentTarget.children[next]?.focus()
47
+ }
48
+
33
49
  return (
34
- <div className={`flex ${wrapHeight} border border-fg-04 rounded overflow-hidden ${className}`}>
50
+ <div
51
+ role="radiogroup"
52
+ aria-label={ariaLabel}
53
+ onKeyDown={handleKeyDown}
54
+ className={['kol-seg', size === 'sm' && 'kol-seg--sm', className].filter(Boolean).join(' ')}
55
+ >
35
56
  {options.map((opt, i) => {
36
57
  const isActive = opt.value === value
37
58
  return (
38
59
  <button
39
60
  key={opt.value}
40
61
  type="button"
41
- onClick={() => onChange?.(opt.value)}
42
- aria-pressed={isActive}
62
+ role="radio"
63
+ aria-checked={isActive}
43
64
  aria-label={opt.ariaLabel}
44
- className={[
45
- `flex-1 ${cellType} inline-flex items-center justify-center cursor-pointer`,
46
- isActive ? 'bg-surface-secondary text-emphasis' : 'text-meta hover:text-emphasis',
47
- i > 0 ? 'border-l border-fg-04' : '',
48
- ].filter(Boolean).join(' ')}
65
+ tabIndex={i === focusIdx ? 0 : -1}
66
+ onClick={() => onChange?.(opt.value)}
67
+ className={['kol-seg-cell', cellType, isActive && 'is-active'].filter(Boolean).join(' ')}
49
68
  >
50
69
  {opt.label}
51
70
  </button>
@@ -31,7 +31,7 @@ const ToggleCheckbox = ({
31
31
  </span>
32
32
  <span className="toggle-checkbox-label kol-helper-12 uppercase tracking-[0.08em]">
33
33
  {label}
34
- {hint ? <span className="ml-2 opacity-60 normal-case tracking-normal text-[10px]">{hint}</span> : null}
34
+ {hint ? <span className="ml-2 opacity-60 normal-case tracking-normal kol-helper-10">{hint}</span> : null}
35
35
  </span>
36
36
  </label>
37
37
  )
@@ -29,7 +29,7 @@ const ToggleSwitch = ({
29
29
  <span className="toggle-switch-label">
30
30
  {label}
31
31
  {hint ? (
32
- <span className="ml-2 opacity-60 normal-case tracking-normal text-[10px]">
32
+ <span className="ml-2 opacity-60 normal-case tracking-normal kol-helper-10">
33
33
  {hint}
34
34
  </span>
35
35
  ) : null}
@@ -14,13 +14,16 @@ import { Icon } from '@kolkrabbi/kol-loader'
14
14
  * where a segmented two-button toggle is overkill.
15
15
  *
16
16
  * Built on the .kol-control shell. Default options use grid-06 / list-01
17
- * icons; consumers can pass `options` to override. For `variant="single"`,
18
- * the FIRST option in `options` is the "off" value; the SECOND is "on".
17
+ * icons; consumers can pass `options` to override. `iconVariant` picks the
18
+ * icon cut for variant="icon" ('stroke' default; 'solid' reads better at
19
+ * 14px). For `variant="single"`, the FIRST option in `options` is the "off"
20
+ * value; the SECOND is "on".
19
21
  */
20
22
  const ViewToggle = ({
21
23
  viewMode,
22
24
  onViewChange,
23
25
  variant = 'text',
26
+ iconVariant = 'stroke',
24
27
  options = [
25
28
  { value: 'grid', label: 'Grid view', icon: 'grid-06' },
26
29
  { value: 'list', label: 'List view', icon: 'list-01' }
@@ -88,7 +91,7 @@ const ViewToggle = ({
88
91
  title={option.label}
89
92
  >
90
93
  {isIconVariant && option.icon ? (
91
- <Icon name={option.icon} size={14} />
94
+ <Icon name={option.icon} size={14} variant={iconVariant} />
92
95
  ) : (
93
96
  option.label
94
97
  )}
@@ -4,29 +4,54 @@
4
4
  * Globs ./svg/<category>/<name>.svg at build time. Usage:
5
5
  * <Graphic category="patterns" name="pattern-05" />
6
6
  *
7
- * When an asset is missing, renders an AssetPlaceholder with the
8
- * category/name labeledmissing graphics are visible rather than silently
9
- * empty. Matches the logo loader pattern (loader lives with assets).
7
+ * The raw SVG strings (~4.8 MB) live in ./graphicData.js behind a single
8
+ * dynamic importtheir chunk streams in parallel with boot instead of
9
+ * bloating the consumer's entry chunk (same pattern as kol-loader's Icon).
10
+ * Until the chunk lands, renders a sized empty box (no layout shift); a
11
+ * genuinely missing asset renders an AssetPlaceholder so it's visible rather
12
+ * than silently empty.
10
13
  */
11
- import AssetPlaceholder from '../primitives/AssetPlaceholder.jsx'
14
+ import { useEffect, useState } from 'react'
15
+ import AssetPlaceholder from '../atoms/AssetPlaceholder.jsx'
12
16
 
13
- const svgModules = import.meta.glob('./svg/**/*.svg', { eager: true, query: '?raw', import: 'default' })
17
+ let RAW = null // category name raw svg, once the chunk resolves
18
+ let loadPromise = null
19
+ const subscribers = new Set()
14
20
 
15
- // category name raw SVG string. Exported so gallery/table consumers
16
- // (e.g. AssetTable) read raw markup without a cross-package glob.
17
- export const GRAPHIC_RAW = Object.entries(svgModules).reduce((acc, [path, svg]) => {
21
+ const loadGraphics = () => {
22
+ if (!loadPromise) {
23
+ loadPromise = import('./graphicData.js').then((mod) => {
24
+ RAW = mod.GRAPHIC_RAW
25
+ subscribers.forEach((fn) => fn())
26
+ return mod
27
+ })
28
+ }
29
+ return loadPromise
30
+ }
31
+
32
+ // Start fetching at module eval — in flight alongside the rest of boot.
33
+ loadGraphics()
34
+
35
+ const useGraphicsReady = () => {
36
+ const [ready, setReady] = useState(() => !!RAW)
37
+ useEffect(() => {
38
+ if (RAW) return undefined
39
+ const cb = () => setReady(true)
40
+ subscribers.add(cb)
41
+ loadGraphics()
42
+ return () => subscribers.delete(cb)
43
+ }, [])
44
+ return ready
45
+ }
46
+
47
+ /* Inventory (category → sorted names) from a keys-only glob — paths resolve at
48
+ * build time without pulling any SVG content into the chunk. */
49
+ export const GRAPHICS = Object.keys(import.meta.glob('./svg/**/*.svg')).reduce((acc, path) => {
18
50
  const [category, file] = path.replace('./svg/', '').split('/')
19
- const name = file.replace('.svg', '')
20
- if (!acc[category]) acc[category] = {}
21
- acc[category][name] = svg
51
+ ;(acc[category] ||= []).push(file.replace('.svg', ''))
22
52
  return acc
23
53
  }, {})
24
-
25
- const GRAPHIC_CACHE = GRAPHIC_RAW
26
-
27
- export const GRAPHICS = Object.fromEntries(
28
- Object.entries(GRAPHIC_CACHE).map(([category, items]) => [category, Object.keys(items).sort()])
29
- )
54
+ for (const names of Object.values(GRAPHICS)) names.sort()
30
55
 
31
56
  export default function Graphic({
32
57
  category,
@@ -36,7 +61,20 @@ export default function Graphic({
36
61
  title,
37
62
  aspectRatio = '1 / 1',
38
63
  }) {
39
- const raw = GRAPHIC_CACHE[category]?.[name]
64
+ const ready = useGraphicsReady()
65
+
66
+ // Chunk still streaming — hold a same-sized box so layout doesn't shift.
67
+ if (!ready) {
68
+ return (
69
+ <span
70
+ aria-hidden="true"
71
+ className={`kol-graphic inline-flex w-full h-auto ${className}`.trim()}
72
+ style={{ aspectRatio, ...style }}
73
+ />
74
+ )
75
+ }
76
+
77
+ const raw = RAW[category]?.[name]
40
78
  if (!raw) {
41
79
  if (import.meta.env.DEV) console.warn(`Graphic: ${category}/${name} not found`)
42
80
  return <AssetPlaceholder category={category} name={name} aspectRatio={aspectRatio} note="pending" className={className} />
@@ -0,0 +1,19 @@
1
+ /**
2
+ * graphicData — the raw SVG map behind <Graphic>.
3
+ *
4
+ * ~4.8 MB of illustration SVGs (some wrap embedded raster images) eager-inline
5
+ * here, but this module is only reached via the dynamic `import()` in
6
+ * Graphic.jsx — the bundler splits it into its own async chunk instead of
7
+ * folding it into the consumer's entry chunk. Same pattern as kol-loader's
8
+ * iconData.js (kol-labs-single 2026-06-19 entry-chunk fix).
9
+ */
10
+ const svgModules = import.meta.glob('./svg/**/*.svg', { eager: true, query: '?raw', import: 'default' })
11
+
12
+ // category → name → raw SVG string.
13
+ export const GRAPHIC_RAW = Object.entries(svgModules).reduce((acc, [path, svg]) => {
14
+ const [category, file] = path.replace('./svg/', '').split('/')
15
+ const name = file.replace('.svg', '')
16
+ if (!acc[category]) acc[category] = {}
17
+ acc[category][name] = svg
18
+ return acc
19
+ }, {})
package/src/index.js CHANGED
@@ -1,59 +1,74 @@
1
1
  /**
2
2
  * @kol/component - Canonical KOL design-system primitives
3
3
  *
4
- * Shared atoms/molecules consumed by both apps/web (via @kol/ui re-export)
5
- * and apps/brand. Components emit canonical kol-* classes; CSS lives in
6
- * @kol/theme (kol-components-*.css).
4
+ * Shared atoms/molecules/organisms consumed by both apps/web (via @kol/ui
5
+ * re-export) and apps/brand. Components emit canonical kol-* classes; CSS
6
+ * lives in @kol/theme (kol-components-*.css).
7
+ *
8
+ * Placement follows the taxonomy rules in
9
+ * docs/taxonomy/01-component-placement.md:
10
+ * atom — nests no KOL component (kol-loader Icon/Graphic are
11
+ * infrastructure, they don't count)
12
+ * molecule — nests at least one KOL component
13
+ * organism — a self-contained composed UI region
14
+ * `scripts/validate-taxonomy.mjs` enforces the closed folder set and the
15
+ * downward-only import rule (atoms never import molecules/organisms).
7
16
  */
8
17
 
9
- export { default as Divider } from './atoms/Divider.jsx'
10
- export { default as DropdownTagFilter } from './molecules/DropdownTagFilter.jsx'
11
- export { default as QuantityInput } from './molecules/QuantityInput.jsx'
12
- export { default as QuantityStepper } from './molecules/QuantityStepper.jsx'
13
- export { default as Pill } from './molecules/Pill.jsx'
14
- export { default as ToggleCheckbox } from './atoms/ToggleCheckbox.jsx'
15
- export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
16
- export { default as ToggleBracket } from './molecules/ToggleBracket.jsx'
17
- export { Icon } from '@kolkrabbi/kol-loader'
18
- export { default as Tag } from './molecules/Tag.jsx'
19
- export { default as Badge } from './molecules/Badge.jsx'
20
- export { default as SectionLabel } from './molecules/SectionLabel.jsx'
21
- export { default as Section } from './molecules/Section.jsx'
22
- export { default as Button } from './atoms/Button.jsx'
23
- export { default as Input } from './atoms/Input.jsx'
24
- export { default as Slider } from './atoms/Slider.jsx'
25
- export { default as Dropdown } from './molecules/Dropdown.jsx'
26
- export { usePopover, PopoverPanel, Tooltip } from './molecules/Popover.jsx'
27
- export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
28
-
29
- /* ── Folded from apps/brand (Phase 4 — brand is canonical, brand wins) ────── */
30
18
  // atoms
19
+ export { default as AssetPlaceholder } from './atoms/AssetPlaceholder.jsx'
31
20
  export { default as Avatar } from './atoms/Avatar.jsx'
32
- export { default as ColorSwatch } from './atoms/ColorSwatch.jsx'
21
+ export { default as Badge } from './atoms/Badge.jsx'
22
+ export { default as Button } from './atoms/Button.jsx'
23
+ export { default as CopyButton } from './atoms/CopyButton.jsx'
24
+ export { default as Divider } from './atoms/Divider.jsx'
25
+ export { default as DropdownTagFilter } from './atoms/DropdownTagFilter.jsx'
26
+ export { default as ExitPreview } from './atoms/ExitPreview.jsx'
27
+ export { default as FullscreenOverlay } from './atoms/FullscreenOverlay.jsx'
28
+ export { default as Input } from './atoms/Input.jsx'
33
29
  export { default as Label } from './atoms/Label.jsx'
30
+ export { default as LabeledControl } from './atoms/LabeledControl.jsx'
31
+ export { default as Pill } from './atoms/Pill.jsx'
32
+ export { usePopover, PopoverPanel, Tooltip } from './atoms/Popover.jsx'
33
+ export { default as QuantityInput } from './atoms/QuantityInput.jsx'
34
+ export { default as QuantityStepper } from './atoms/QuantityStepper.jsx'
35
+ export { default as Section } from './atoms/Section.jsx'
36
+ export { default as SectionLabel } from './atoms/SectionLabel.jsx'
37
+ export { default as SegmentedToggle } from './atoms/SegmentedToggle.jsx'
34
38
  export { default as Stepper } from './atoms/Stepper.jsx'
39
+ export { default as Tag } from './atoms/Tag.jsx'
35
40
  export { default as Textarea } from './atoms/Textarea.jsx'
41
+ export { default as ToggleBracket } from './atoms/ToggleBracket.jsx'
42
+ export { default as ToggleCheckbox } from './atoms/ToggleCheckbox.jsx'
43
+ export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
36
44
  export { default as TransparentX } from './atoms/TransparentX.jsx'
45
+ export { default as ViewToggle } from './atoms/ViewToggle.jsx'
46
+
37
47
  // molecules
38
- export { default as ContentFilters } from './molecules/ContentFilters.jsx'
39
- export { default as LabeledControl } from './molecules/LabeledControl.jsx'
48
+ export { Accordion, AccordionPanel } from './molecules/Accordion.jsx'
49
+ export { default as CodeBlock } from './molecules/CodeBlock.jsx'
50
+ export { default as ColorSwatch } from './molecules/ColorSwatch.jsx'
51
+ export { default as Dropdown } from './molecules/Dropdown.jsx'
52
+ export { default as Image } from './molecules/Image.jsx'
53
+ export { default as MediaCard } from './molecules/MediaCard.jsx'
54
+ export { default as MediaRow } from './molecules/MediaRow.jsx'
55
+ export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
40
56
  export { MenuPopover } from './molecules/MenuPopover.jsx'
41
57
  export { ModalProvider, useModal } from './molecules/Modal.jsx'
42
58
  export { default as PropertyInput } from './molecules/PropertyInput.jsx'
43
- export { default as SegmentedToggle } from './molecules/SegmentedToggle.jsx'
44
- export { default as ViewToggle } from './molecules/ViewToggle.jsx'
45
- // primitives
46
- export { Accordion, AccordionPanel } from './primitives/Accordion.jsx'
47
- export { default as AssetPlaceholder } from './primitives/AssetPlaceholder.jsx'
48
- export { default as Carousel } from './primitives/Carousel.jsx'
49
- export { default as CodeBlock } from './primitives/CodeBlock.jsx'
50
- export { default as ExitPreview } from './primitives/ExitPreview.jsx'
51
- export { default as FullscreenOverlay } from './primitives/FullscreenOverlay.jsx'
52
- export { default as Image } from './primitives/Image.jsx'
53
- // graphics (SVG illustration loader — globs its own ./graphics/svg/**)
54
- export { default as Graphic, GRAPHICS, GRAPHIC_RAW } from './graphics/Graphic.jsx'
59
+ export { default as Slider } from './molecules/Slider.jsx'
60
+
55
61
  // organisms
62
+ export { default as Carousel } from './organisms/Carousel.jsx'
63
+ export { default as ContentFilters } from './organisms/ContentFilters.jsx'
56
64
  export { default as Table } from './organisms/Table.jsx'
65
+
66
+ // loaders (re-export — infrastructure, documented on /docs/loaders)
67
+ export { Icon } from '@kolkrabbi/kol-loader'
68
+
69
+ // graphics (SVG illustration loader — globs its own ./graphics/svg/**)
70
+ export { default as Graphic, GRAPHICS } from './graphics/Graphic.jsx'
71
+
57
72
  // hooks
58
73
  export { default as useReveal } from './hooks/useReveal.js'
59
74
  export { default as useScrollSpy } from './hooks/useScrollSpy.js'
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Accordion — collapsible panel group.
3
3
  *
4
+ * taxonomy-ok: molecule — Accordion nests AccordionPanel in this same file,
5
+ * which the import-based validator can't see.
6
+ *
4
7
  * Composition-based: <Accordion> wraps <AccordionPanel> children. Each panel
5
8
  * owns its open/closed state independently (additive — multiple can be open).
6
9
  * For single-open behavior, manage state from the parent and pass controlled
@@ -37,7 +40,7 @@ export function AccordionPanel({
37
40
  >
38
41
  <span className="kol-accordion-title kol-helper-12 uppercase tracking-widest">{title}</span>
39
42
  {meta && <span className="kol-accordion-meta kol-helper-12 uppercase tracking-widest text-fg-48 ml-auto mr-3">{meta}</span>}
40
- <span className="kol-accordion-chevron font-mono text-[18px] text-fg-48 min-w-3 transition-colors duration-[120ms] ml-auto" aria-hidden="true">{open ? '−' : '+'}</span>
43
+ <span className="kol-accordion-chevron kol-helper-16 text-fg-48 min-w-3 transition-colors duration-[120ms] ml-auto" aria-hidden="true">{open ? '−' : '+'}</span>
41
44
  </button>
42
45
  {open && <div className="kol-accordion-body pt-2 pb-6">{children}</div>}
43
46
  </div>
@@ -0,0 +1,11 @@
1
+ import CopyButton from '../atoms/CopyButton.jsx'
2
+
3
+ export default function CodeBlock({ children, language }) {
4
+ return (
5
+ <div className="kol-codeblock">
6
+ {language && <span className="kol-codeblock-lang">{language}</span>}
7
+ <CopyButton text={String(children)} className="kol-codeblock-copy" />
8
+ <pre><code>{children}</code></pre>
9
+ </div>
10
+ )
11
+ }
@@ -34,7 +34,7 @@
34
34
  * onClick — if provided, renders as <button>; else <span>.
35
35
  * title — passes through.
36
36
  */
37
- import TransparentX from './TransparentX'
37
+ import TransparentX from '../atoms/TransparentX'
38
38
 
39
39
  const SIZE_CLASSES = {
40
40
  fill: 'w-full aspect-square',
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-loader'
3
3
  import { MenuDropdownItem } from './MenuItem.jsx'
4
- import { PopoverPanel, usePopover } from './Popover.jsx'
4
+ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
5
5
 
6
6
  const SIZE_MAP = {
7
7
  sm: { fontSize: 12, paddingY: 4, paddingX: 12, radius: 14, panelRadius: 14, icon: 10 },
@@ -17,9 +17,10 @@ const Dropdown = ({
17
17
  onChange,
18
18
  size,
19
19
  variant = 'default',
20
+ defaultOpen = false,
20
21
  className = ''
21
22
  }) => {
22
- const [isOpen, setIsOpen] = useState(false)
23
+ const [isOpen, setIsOpen] = useState(defaultOpen)
23
24
  const [resolvedSize, setResolvedSize] = useState('md')
24
25
  const [dropdownWidth, setDropdownWidth] = useState('100px')
25
26
 
@@ -6,7 +6,7 @@
6
6
  * visible at its intended size (aspect-ratio preserved).
7
7
  */
8
8
  import { useState } from 'react'
9
- import AssetPlaceholder from './AssetPlaceholder'
9
+ import AssetPlaceholder from '../atoms/AssetPlaceholder'
10
10
 
11
11
  export default function Image({
12
12
  src,
@@ -0,0 +1,97 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ /* taxonomy-ok: nests kol-loader's Icon (a package import the relative-import
4
+ * check can't see) plus the same-file SelectIndicator. */
5
+
6
+ /**
7
+ * SelectIndicator — passive square check indicator for multi-select rows and
8
+ * cards. `on` = checked. Deliberately NOT ToggleCheckbox: that is a labeled
9
+ * form control with a real <input>, which double-fires inside a click-target
10
+ * card and misleads assistive tech — here the CARD is the toggle, this is
11
+ * only its visual state. Inline CSS vars because `bg-fg-default` isn't a
12
+ * generated Tailwind utility; the checked fill reads from
13
+ * `--kol-surface-on-primary` (solid fg, theme-correct).
14
+ *
15
+ * Shared by MediaCard and MediaRow; not exported from the package barrel.
16
+ */
17
+ export function SelectIndicator({ on = false }) {
18
+ return (
19
+ <span
20
+ className="w-4 h-4 shrink-0 rounded-sm border flex items-center justify-center transition-colors"
21
+ style={
22
+ on
23
+ ? { background: 'var(--kol-surface-on-primary)', borderColor: 'var(--kol-surface-on-primary)' }
24
+ : { background: 'var(--kol-fg-absolute-16, rgba(0,0,0,0.15))', borderColor: 'var(--kol-fg-absolute-48, rgba(0,0,0,0.4))' }
25
+ }
26
+ aria-hidden="true"
27
+ >
28
+ {on && <Icon name="check" size={11} style={{ color: 'var(--kol-surface-primary)' }} />}
29
+ </span>
30
+ )
31
+ }
32
+
33
+ /**
34
+ * MediaCard — grid tile for one media object: square thumbnail with a
35
+ * top-right download overlay (or a top-left select checkbox in select mode),
36
+ * then name / meta / actions stacked below. The grid-view counterpart to
37
+ * MediaRow (same slot contract).
38
+ *
39
+ * Presentational — the parent supplies rendered slots; no media loading,
40
+ * fetch, or rename logic lives here. Grid track sizing belongs to the parent
41
+ * list; the card just fills its cell.
42
+ *
43
+ * @param {ReactNode} thumb square thumbnail (image/video/placeholder)
44
+ * @param {ReactNode} name name cell (plain text or an inline editor)
45
+ * @param {string} meta one-line secondary text (e.g. "1.2 MB · 2026-06-19")
46
+ * @param {ReactNode} actions row of action buttons (hidden in select mode)
47
+ * @param {string} downloadHref href for the overlay download button (omit to hide)
48
+ * @param {boolean} selectMode selection mode on → checkbox replaces download, whole card toggles
49
+ * @param {boolean} selected this card is selected (stronger border + checked box)
50
+ * @param {Function} onSelect (event) => void — card click in select mode; gets shiftKey for range
51
+ */
52
+ export default function MediaCard({
53
+ thumb,
54
+ name,
55
+ meta,
56
+ actions,
57
+ downloadHref,
58
+ selectMode = false,
59
+ selected = false,
60
+ onSelect,
61
+ }) {
62
+ return (
63
+ <li
64
+ onClick={selectMode ? onSelect : undefined}
65
+ className={`flex flex-col rounded overflow-hidden border bg-fg-02 ${selectMode ? 'cursor-pointer select-none' : ''}`}
66
+ style={{ borderColor: selected ? 'var(--kol-fg-64)' : 'var(--kol-fg-12)' }}
67
+ >
68
+ <div className="aspect-square relative">
69
+ {thumb}
70
+ {selectMode ? (
71
+ <span
72
+ className="absolute top-3 left-3 rounded p-1"
73
+ style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
74
+ >
75
+ <SelectIndicator on={selected} />
76
+ </span>
77
+ ) : downloadHref ? (
78
+ <a
79
+ href={downloadHref}
80
+ aria-label="Download"
81
+ title="Download"
82
+ className="absolute top-3 right-3 inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-absolute-24 transition-colors"
83
+ style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
84
+ onClick={(e) => e.stopPropagation()}
85
+ >
86
+ <Icon name="download" size={16} />
87
+ </a>
88
+ ) : null}
89
+ </div>
90
+ <div className="p-3 flex flex-col gap-2">
91
+ {name}
92
+ <p className="kol-mono-12 text-fg-48">{meta}</p>
93
+ {!selectMode && actions}
94
+ </div>
95
+ </li>
96
+ )
97
+ }
@@ -0,0 +1,49 @@
1
+ import { SelectIndicator } from './MediaCard.jsx'
2
+
3
+ /**
4
+ * MediaRow — list row for one media object: optional select checkbox, small
5
+ * thumbnail, name (flex), fixed-width date + size columns, then actions.
6
+ * The list-view counterpart to MediaCard (same slot contract).
7
+ *
8
+ * Presentational — the parent supplies rendered slots; interaction outside
9
+ * select mode lives in the thumb / name / actions slots. Column widths are
10
+ * consumer-tunable via `dateWidth` / `sizeWidth`.
11
+ *
12
+ * @param {ReactNode} thumb small thumbnail (48px square)
13
+ * @param {ReactNode} name name cell (plain text or an inline editor)
14
+ * @param {string} date right-aligned date column
15
+ * @param {string} size right-aligned size column
16
+ * @param {ReactNode} actions row of action buttons (hidden in select mode)
17
+ * @param {string} dateWidth Tailwind width class for the date column
18
+ * @param {string} sizeWidth Tailwind width class for the size column
19
+ * @param {boolean} selectMode selection mode on → checkbox shown, whole row toggles, actions hidden
20
+ * @param {boolean} selected this row is selected (highlight + checked box)
21
+ * @param {Function} onSelect (event) => void — row click in select mode; gets shiftKey for range
22
+ */
23
+ export default function MediaRow({
24
+ thumb,
25
+ name,
26
+ date,
27
+ size,
28
+ actions,
29
+ dateWidth = 'w-24',
30
+ sizeWidth = 'w-20',
31
+ selectMode = false,
32
+ selected = false,
33
+ onSelect,
34
+ }) {
35
+ return (
36
+ <li
37
+ onClick={selectMode ? onSelect : undefined}
38
+ className={`flex items-center gap-3 py-2 border-b ${selectMode ? 'cursor-pointer select-none' : ''} ${selected ? 'bg-fg-08' : ''}`}
39
+ style={{ borderColor: 'var(--kol-fg-08)' }}
40
+ >
41
+ {selectMode && <SelectIndicator on={selected} />}
42
+ <div className="w-12 h-12 shrink-0 rounded overflow-hidden">{thumb}</div>
43
+ <div className="flex-1 min-w-0">{name}</div>
44
+ <p className={`kol-mono-12 text-fg-32 shrink-0 text-right ${dateWidth}`}>{date}</p>
45
+ <p className={`kol-mono-12 text-fg-48 shrink-0 text-right ${sizeWidth}`}>{size}</p>
46
+ {!selectMode && <div className="shrink-0">{actions}</div>}
47
+ </li>
48
+ )
49
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-loader'
3
- import { PopoverPanel, usePopover } from './Popover.jsx'
3
+ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
4
4
 
5
5
  /**
6
6
  * MenuItem — top-level menu entry. Trigger button + popover panel.
@@ -26,8 +26,9 @@ export function MenuItem({
26
26
  panelClassName = '',
27
27
  panelStyle,
28
28
  buttonClassName = '',
29
+ defaultOpen = false,
29
30
  }) {
30
- const [open, setOpen] = useState(false)
31
+ const [open, setOpen] = useState(defaultOpen)
31
32
  const popover = usePopover({
32
33
  open,
33
34
  onOpenChange: setOpen,
@@ -1,103 +1,18 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { MenuItem as MenuTrigger } from './MenuItem.jsx'
2
2
 
3
3
  /**
4
- * MenuPopover — generic action-menu / popover primitive.
4
+ * MenuPopover — DEPRECATED alias of MenuItem (2026-07-02 menu-family
5
+ * unification).
5
6
  *
6
- * <MenuPopover label="File">
7
- * <MenuItem onClick={…}>Save</MenuItem>
8
- * <MenuItem onClick={…}>Export…</MenuItem>
9
- * </MenuPopover>
10
- *
11
- * Opens on click, closes on outside-click + Escape, anchors to the trigger
12
- * via getBoundingClientRect + position:fixed so it escapes overflow:auto
13
- * clipping. Pass `panelClassName` to size the panel (e.g. wider for
14
- * Templates).
15
- *
16
- * For value-list selection (single value, active state) use `Dropdown`
17
- * instead — this primitive is for action menus / popover panels that
18
- * hold arbitrary children.
7
+ * The two triggers had an identical API (label, children incl. ({ close })
8
+ * render-prop, align, panelClassName, panelStyle, buttonClassName,
9
+ * defaultOpen) and did the same job — MenuPopover with hand-rolled
10
+ * fixed positioning, MenuItem on floating-ui (portal, auto-flip, focus
11
+ * management). One implementation now: MenuItem. This alias keeps existing
12
+ * call-sites working; migrate imports to MenuItem. Removal in the next major.
19
13
  */
20
- export function MenuPopover({
21
- label,
22
- children,
23
- align = 'start',
24
- panelClassName = '',
25
- panelStyle,
26
- buttonClassName = '',
27
- }) {
28
- const [open, setOpen] = useState(false)
29
- const wrapRef = useRef(null)
30
- const buttonRef = useRef(null)
31
- const [panelPos, setPanelPos] = useState(null)
32
-
33
- useEffect(() => {
34
- if (!open) return
35
- const onDown = (e) => {
36
- if (!wrapRef.current?.contains(e.target)) setOpen(false)
37
- }
38
- const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
39
- document.addEventListener('mousedown', onDown)
40
- document.addEventListener('keydown', onKey)
41
- return () => {
42
- document.removeEventListener('mousedown', onDown)
43
- document.removeEventListener('keydown', onKey)
44
- }
45
- }, [open])
46
-
47
- useEffect(() => {
48
- if (!open) { setPanelPos(null); return }
49
- const update = () => {
50
- const rect = buttonRef.current?.getBoundingClientRect()
51
- if (!rect) return
52
- setPanelPos({ top: rect.bottom + 4, left: rect.left, right: rect.right })
53
- }
54
- update()
55
- window.addEventListener('resize', update)
56
- window.addEventListener('scroll', update, true)
57
- return () => {
58
- window.removeEventListener('resize', update)
59
- window.removeEventListener('scroll', update, true)
60
- }
61
- }, [open])
62
-
63
- const close = () => setOpen(false)
64
-
65
- const positioned = panelPos && (
66
- align === 'end'
67
- ? { top: panelPos.top, right: window.innerWidth - panelPos.right }
68
- : { top: panelPos.top, left: panelPos.left }
69
- )
70
-
71
- return (
72
- <div ref={wrapRef} className="relative inline-block">
73
- <button
74
- ref={buttonRef}
75
- type="button"
76
- onClick={() => setOpen((v) => !v)}
77
- aria-haspopup="menu"
78
- aria-expanded={open}
79
- className={`kol-helper-12 px-3 h-8 inline-flex items-center gap-1 rounded text-meta hover:text-emphasis transition-colors ${buttonClassName}`}
80
- >
81
- <span>{label}</span>
82
- <svg width="10" height="10" viewBox="0 0 12 12" aria-hidden="true">
83
- <path d="m3 5 3 3 3-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" fill="none" />
84
- </svg>
85
- </button>
86
- {open && positioned && (
87
- <div
88
- role="menu"
89
- className={`fixed z-[1000] bg-surface-primary border border-fg-08 rounded shadow-lg ${panelClassName}`}
90
- style={{ ...positioned, ...panelStyle }}
91
- onClick={(e) => {
92
- /* close on item click — items inside fire their handler then bubble. */
93
- if (e.target.closest('[data-menu-item]')) close()
94
- }}
95
- >
96
- {typeof children === 'function' ? children({ close }) : children}
97
- </div>
98
- )}
99
- </div>
100
- )
14
+ export function MenuPopover(props) {
15
+ return <MenuTrigger {...props} />
101
16
  }
102
17
 
103
18
  /**
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from 'react'
2
- import Input from './Input.jsx'
2
+ import Input from '../atoms/Input.jsx'
3
3
 
4
4
  /**
5
5
  * Slider — range slider with label and an editable value readout.
@@ -1,8 +1,8 @@
1
1
  import { useState, useMemo, useRef, useEffect } from 'react'
2
- import Tag from './Tag.jsx'
2
+ import Tag from '../atoms/Tag.jsx'
3
3
  import Divider from '../atoms/Divider.jsx'
4
4
  import { Icon } from '@kolkrabbi/kol-loader'
5
- import ViewToggle from './ViewToggle'
5
+ import ViewToggle from '../atoms/ViewToggle'
6
6
 
7
7
  /**
8
8
  * ContentFilters — universal filter component for content grids.
@@ -1,41 +0,0 @@
1
- import { useState } from 'react'
2
-
3
- export default function CodeBlock({ children, language }) {
4
- const [copied, setCopied] = useState(false)
5
-
6
- const onCopy = async () => {
7
- try {
8
- await navigator.clipboard.writeText(String(children))
9
- setCopied(true)
10
- setTimeout(() => setCopied(false), 1800)
11
- } catch {
12
- /* clipboard blocked — silent */
13
- }
14
- }
15
-
16
- return (
17
- <div className="kol-codeblock">
18
- {language && <span className="kol-codeblock-lang">{language}</span>}
19
- <button
20
- type="button"
21
- className="kol-codeblock-copy"
22
- onClick={onCopy}
23
- aria-label={copied ? 'Copied' : 'Copy to clipboard'}
24
- title={copied ? 'Copied' : 'Copy'}
25
- >
26
- {copied ? (
27
- <svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
28
- <path d="M5 10 L9 14 L15 6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
29
- </svg>
30
- ) : (
31
- <svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
32
- <rect x="6" y="3" width="10" height="12" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4" />
33
- <rect x="4" y="6" width="10" height="12" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4" />
34
- </svg>
35
- )}
36
- <span className="leading-none">{copied ? 'Copied' : 'Copy'}</span>
37
- </button>
38
- <pre><code>{children}</code></pre>
39
- </div>
40
- )
41
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes