@kolkrabbi/kol-component 0.1.2 → 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 (110) hide show
  1. package/package.json +12 -4
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
  5. package/src/atoms/Avatar.jsx +4 -4
  6. package/src/atoms/Button.jsx +23 -9
  7. package/src/atoms/CopyButton.jsx +50 -0
  8. package/src/atoms/CurveOverlay.jsx +180 -0
  9. package/src/atoms/DocsToc.jsx +48 -0
  10. package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
  11. package/src/atoms/EmptyState.jsx +22 -0
  12. package/src/atoms/Figure.jsx +27 -0
  13. package/src/atoms/HlsVideo.jsx +56 -0
  14. package/src/atoms/Input.jsx +7 -2
  15. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  16. package/src/atoms/PriceDisplay.jsx +34 -0
  17. package/src/atoms/ProsePreview.jsx +53 -0
  18. package/src/atoms/RotaryDial.jsx +150 -0
  19. package/src/atoms/SearchInput.jsx +108 -0
  20. package/src/{molecules → atoms}/SegmentedToggle.jsx +33 -14
  21. package/src/atoms/TextPressure.jsx +331 -0
  22. package/src/atoms/TiltCard.jsx +127 -0
  23. package/src/atoms/ToggleCheckbox.jsx +1 -1
  24. package/src/atoms/ToggleSwitch.jsx +1 -1
  25. package/src/atoms/TypeSample.jsx +50 -0
  26. package/src/atoms/TypeSpecCard.jsx +42 -0
  27. package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
  28. package/src/graphics/Graphic.jsx +56 -18
  29. package/src/graphics/graphicData.js +19 -0
  30. package/src/hooks/cssVar.js +53 -0
  31. package/src/hooks/useAxisAnimation.js +91 -0
  32. package/src/hooks/usePrefersReducedMotion.js +21 -0
  33. package/src/hooks/useTilt.js +49 -0
  34. package/src/index.js +119 -39
  35. package/src/{primitives → molecules}/Accordion.jsx +4 -1
  36. package/src/molecules/AlignmentGrid.jsx +53 -0
  37. package/src/molecules/ArticleCard.jsx +178 -0
  38. package/src/molecules/CardFeatureItem.jsx +130 -0
  39. package/src/molecules/CodeBlock.jsx +11 -0
  40. package/src/molecules/ColorInputRow.jsx +179 -0
  41. package/src/molecules/ColorRamp.jsx +114 -0
  42. package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
  43. package/src/molecules/Dropdown.jsx +3 -2
  44. package/src/molecules/FramedMediaBand.jsx +56 -0
  45. package/src/{primitives → molecules}/Image.jsx +1 -1
  46. package/src/molecules/ImageBlock.jsx +34 -0
  47. package/src/molecules/MediaCard.jsx +97 -0
  48. package/src/molecules/MediaRow.jsx +49 -0
  49. package/src/molecules/MenuItem.jsx +3 -2
  50. package/src/molecules/MenuPopover.jsx +11 -96
  51. package/src/molecules/SelectionOverlay.jsx +108 -0
  52. package/src/molecules/ShapeDropdown.jsx +92 -0
  53. package/src/molecules/ShellDrawer.jsx +169 -0
  54. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  55. package/src/{atoms → molecules}/Slider.jsx +1 -1
  56. package/src/molecules/SpecList.jsx +30 -0
  57. package/src/molecules/SpectrumControls.jsx +504 -0
  58. package/src/molecules/SwatchControls.jsx +217 -0
  59. package/src/molecules/TabsRow.jsx +87 -0
  60. package/src/molecules/VideoBlock.jsx +86 -0
  61. package/src/molecules/WorkListItem.jsx +83 -0
  62. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  63. package/src/organisms/ArticleHeader.jsx +95 -0
  64. package/src/organisms/AsciiCursor.jsx +526 -0
  65. package/src/organisms/BentoCard.jsx +187 -0
  66. package/src/organisms/Canvas.jsx +299 -0
  67. package/src/organisms/ColorLoader.jsx +155 -0
  68. package/src/{molecules → organisms}/ContentFilters.jsx +2 -2
  69. package/src/organisms/CtaGlobal.jsx +67 -0
  70. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  71. package/src/organisms/EditorShell.jsx +111 -0
  72. package/src/organisms/ErrorBoundary.jsx +70 -0
  73. package/src/organisms/FeatureSplit.jsx +82 -0
  74. package/src/organisms/FeaturedCarousel.jsx +258 -0
  75. package/src/organisms/FeaturesCardSection.jsx +90 -0
  76. package/src/organisms/FullBleedHero.jsx +111 -0
  77. package/src/organisms/GalleryCarousel.jsx +83 -0
  78. package/src/organisms/LoaderOverlay.jsx +30 -0
  79. package/src/organisms/MediaViewer.jsx +95 -0
  80. package/src/organisms/NewsletterBand.jsx +122 -0
  81. package/src/organisms/ParallaxShelf.jsx +141 -0
  82. package/src/organisms/PortableTextRenderer.jsx +115 -0
  83. package/src/organisms/ProductDetailLayout.jsx +189 -0
  84. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  85. package/src/organisms/SpectrumGrid.jsx +90 -0
  86. package/src/organisms/StackHero.jsx +83 -0
  87. package/src/organisms/WorkCard.jsx +120 -0
  88. package/src/organisms/WorkViewToggle.jsx +170 -0
  89. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  90. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  91. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  92. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  93. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  94. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  95. package/src/organisms/foundry/glyphData.js +30 -0
  96. package/src/organisms/foundry/index.js +21 -0
  97. package/src/primitives/CodeBlock.jsx +0 -41
  98. /package/src/{molecules → atoms}/Badge.jsx +0 -0
  99. /package/src/{primitives → atoms}/ExitPreview.jsx +0 -0
  100. /package/src/{primitives → atoms}/FullscreenOverlay.jsx +0 -0
  101. /package/src/{molecules → atoms}/LabeledControl.jsx +0 -0
  102. /package/src/{molecules → atoms}/Pill.jsx +0 -0
  103. /package/src/{molecules → atoms}/Popover.jsx +0 -0
  104. /package/src/{molecules → atoms}/QuantityInput.jsx +0 -0
  105. /package/src/{molecules → atoms}/QuantityStepper.jsx +0 -0
  106. /package/src/{molecules → atoms}/Section.jsx +0 -0
  107. /package/src/{molecules → atoms}/SectionLabel.jsx +0 -0
  108. /package/src/{molecules → atoms}/Tag.jsx +0 -0
  109. /package/src/{molecules → atoms}/ToggleBracket.jsx +0 -0
  110. /package/src/{primitives → organisms}/Carousel.jsx +0 -0
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.1.2",
3
+ "version": "0.3.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",
7
7
  "main": "./src/index.js",
8
8
  "module": "./src/index.js",
9
9
  "exports": {
10
- ".": "./src/index.js"
10
+ ".": "./src/index.js",
11
+ "./foundry": "./src/organisms/foundry/index.js"
11
12
  },
12
13
  "files": [
13
14
  "src",
@@ -23,16 +24,23 @@
23
24
  "dependencies": {
24
25
  "@floating-ui/react": "^0.27.19",
25
26
  "embla-carousel-react": "^8.6.0",
26
- "@kolkrabbi/kol-loader": "0.2.0"
27
+ "@kolkrabbi/kol-loader": "0.3.0"
27
28
  },
28
29
  "peerDependencies": {
29
30
  "react": "^18.3.0 || ^19.0.0",
30
31
  "react-dom": "^18.3.0 || ^19.0.0",
31
- "react-router-dom": "^6.0.0 || ^7.0.0"
32
+ "react-router-dom": "^6.0.0 || ^7.0.0",
33
+ "framer-motion": "^12.0.0",
34
+ "gsap": "^3.13.0",
35
+ "hls.js": "^1.6.0",
36
+ "opentype.js": "^1.3.4"
32
37
  },
33
38
  "peerDependenciesMeta": {
34
39
  "react-router-dom": {
35
40
  "optional": true
41
+ },
42
+ "opentype.js": {
43
+ "optional": true
36
44
  }
37
45
  },
38
46
  "repository": {
@@ -0,0 +1,108 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { gsap } from 'gsap'
3
+ import { ScrollTrigger } from 'gsap/ScrollTrigger'
4
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
5
+
6
+ gsap.registerPlugin(ScrollTrigger)
7
+
8
+ /**
9
+ * AnimatedTitle — scroll-triggered heading that reveals its words one by
10
+ * one as it scrolls into view: each word starts far off-screen right,
11
+ * rotated in 3D, and flies into place with a fast stagger (GSAP +
12
+ * ScrollTrigger; plays entering the viewport, reverses scrolling back
13
+ * out). `title` is an HTML-bearing string: the literal '<br />' splits
14
+ * lines, spaces split words, and each word renders via
15
+ * dangerouslySetInnerHTML so it can carry inline markup (<b>,
16
+ * <span class>) — pass trusted strings only.
17
+ *
18
+ * Typography and color are inherited from the consumer via
19
+ * `containerClass` — the component owns only the split and the motion.
20
+ * When the user prefers reduced motion, no GSAP runs at all and the
21
+ * title renders fully visible and static.
22
+ *
23
+ * @param {string} title HTML string; '<br />' → lines, ' ' → words. Required. Trusted content only
24
+ * @param {string} containerClass classes on the root (typography/color live here)
25
+ * @param {object} style inline style on the root
26
+ * @param {string} lineClass extra classes on each line row, after the flex defaults
27
+ * @param {number} stagger seconds between word starts
28
+ * @param {string} start ScrollTrigger start position
29
+ * @param {string} end ScrollTrigger end position
30
+ * @param {boolean} immediate skip ScrollTrigger, play the reveal on mount (non-scrolling stages)
31
+ */
32
+ export default function AnimatedTitle({
33
+ title,
34
+ containerClass,
35
+ style,
36
+ lineClass,
37
+ stagger = 0.02,
38
+ start = '100 bottom',
39
+ end = 'center bottom',
40
+ immediate = false,
41
+ }) {
42
+ const containerRef = useRef(null)
43
+ const reducedMotion = usePrefersReducedMotion()
44
+
45
+ useEffect(() => {
46
+ if (reducedMotion) return undefined
47
+
48
+ const ctx = gsap.context(() => {
49
+ const words = containerRef.current.querySelectorAll('.animatedWord')
50
+
51
+ gsap.set(words, {
52
+ opacity: 0,
53
+ x: '150vw',
54
+ y: 50,
55
+ rotateY: -45,
56
+ rotateX: 15,
57
+ })
58
+
59
+ const titleAnimation = gsap.timeline(
60
+ immediate
61
+ ? undefined
62
+ : {
63
+ scrollTrigger: {
64
+ trigger: containerRef.current,
65
+ start,
66
+ end,
67
+ toggleActions: 'play none none reverse',
68
+ },
69
+ },
70
+ )
71
+
72
+ titleAnimation.to(
73
+ words,
74
+ {
75
+ opacity: 1,
76
+ x: 0,
77
+ y: 0,
78
+ rotateY: 0,
79
+ rotateX: 0,
80
+ ease: 'power2.inOut',
81
+ stagger,
82
+ },
83
+ 0,
84
+ )
85
+ }, containerRef)
86
+
87
+ return () => ctx.revert()
88
+ }, [title, reducedMotion, immediate, stagger, start, end])
89
+
90
+ return (
91
+ <div ref={containerRef} className={containerClass} style={style}>
92
+ {title.split('<br />').map((line, index) => (
93
+ <div
94
+ key={index}
95
+ className={`flex items-center justify-center flex-wrap gap-2 md:gap-3 ${lineClass || ''}`}
96
+ >
97
+ {line.split(' ').map((word, i) => (
98
+ <span
99
+ key={i}
100
+ className="animatedWord"
101
+ dangerouslySetInnerHTML={{ __html: word }}
102
+ />
103
+ ))}
104
+ </div>
105
+ ))}
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * AssetGrid — thin responsive N-column grid for tiling asset figures
3
+ * (swatches, placeholders, spec cards). Fixed 2/3/4-column layout that
4
+ * collapses to 2 columns under the md breakpoint and to 1 column under
5
+ * 480px, all via Tailwind responsive utilities — no CSS of its own.
6
+ *
7
+ * Purely structural — no styling of the cells, no spacing outside itself
8
+ * (the source's `mt-8` was a call-site concern and was dropped). Children
9
+ * just fill the tracks.
10
+ *
11
+ * @param {2|3|4} cols column count at full width (unknown values fall back to 3)
12
+ * @param {string} gap Tailwind gap utility for both axes
13
+ * @param {ReactNode} children grid items
14
+ * @param {string} className extra classes appended
15
+ */
16
+
17
+ const COLS = {
18
+ 2: 'grid-cols-1 min-[480px]:grid-cols-2',
19
+ 3: 'grid-cols-1 min-[480px]:grid-cols-2 md:grid-cols-3',
20
+ 4: 'grid-cols-1 min-[480px]:grid-cols-2 md:grid-cols-4',
21
+ }
22
+
23
+ export default function AssetGrid({ cols = 3, gap = 'gap-4', children, className = '' }) {
24
+ return (
25
+ <div className={`grid ${COLS[cols] || COLS[3]} ${gap} ${className}`.trim()}>
26
+ {children}
27
+ </div>
28
+ )
29
+ }
@@ -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 = '' }) {
@@ -1,3 +1,4 @@
1
+ import { isValidElement } from 'react'
1
2
  import { Icon } from '@kolkrabbi/kol-loader'
2
3
 
3
4
  /**
@@ -28,6 +29,8 @@ import { Icon } from '@kolkrabbi/kol-loader'
28
29
  * @param {string} props.type - Button type attribute (default: 'button')
29
30
  * @param {boolean} props.disabled - Disabled state
30
31
  * @param {boolean} props.selected - Selected/active state (toggle highlight)
32
+ * @param {ElementType|ReactNode} props.iconComponent - Icon renderer seam — a component that receives `{ name, size, className, style }` in place of the DS Icon (custom icon registries plug in here), or a pre-rendered node dropped in verbatim where the glyph would go. Defaults to DS Icon.
33
+ * @param {boolean} props.pressed - Toggle state — sets `aria-pressed` (true/false) and `kol-btn-pressed`; a `quiet` button drops its dimming while pressed. Leave undefined for non-toggle buttons.
31
34
  */
32
35
  const Button = ({
33
36
  children,
@@ -50,6 +53,8 @@ const Button = ({
50
53
  type = 'button',
51
54
  disabled = false,
52
55
  selected = false,
56
+ iconComponent,
57
+ pressed,
53
58
  ...props
54
59
  }) => {
55
60
  const resolvedIconSize = iconSize ?? (size === 'sm' ? 14 : size === 'lg' ? 18 : 16)
@@ -77,30 +82,36 @@ const Button = ({
77
82
 
78
83
  // Add kol-btn-animate class if animateIcon is true to disable default hover states
79
84
  const animateClass = animateIcon ? 'kol-btn-animate' : ''
80
- const quietClass = quiet ? 'kol-btn-quiet' : ''
85
+ const quietClass = quiet && !pressed ? 'kol-btn-quiet' : ''
81
86
  const selectedClass = selected ? 'kol-btn-selected' : ''
87
+ const pressedClass = pressed ? 'kol-btn-pressed' : ''
82
88
 
83
- const combinedClass = `kol-btn ${variantClass} ${sizeClass} ${animateClass} ${quietClass} ${selectedClass} ${className}`.trim().replace(/\s+/g, ' ')
89
+ const combinedClass = `kol-btn ${variantClass} ${sizeClass} ${animateClass} ${quietClass} ${selectedClass} ${pressedClass} ${className}`.trim().replace(/\s+/g, ' ')
84
90
 
85
91
  // Render icon with optional hover state
86
92
  const renderIcon = (iconName, iconHoverName) => {
87
93
  if (!iconName && !iconHoverName) return null
88
94
 
95
+ // Icon injection seam — a component type resolves names against a custom
96
+ // registry (EditorIcon etc.); a pre-rendered node drops in verbatim.
97
+ if (isValidElement(iconComponent)) return iconComponent
98
+ const IconCmp = iconComponent || Icon
99
+
89
100
  // If no hover icon, render single icon
90
101
  if (!iconHoverName) {
91
- return <Icon name={iconName} size={resolvedIconSize} />
102
+ return <IconCmp name={iconName} size={resolvedIconSize} />
92
103
  }
93
104
 
94
105
  // Render both default and hover icons with positioning
95
106
  return (
96
107
  <span className="kol-icon-swap-container" style={{ position: 'relative', display: 'inline-flex', width: resolvedIconSize, height: resolvedIconSize, overflow: 'hidden' }}>
97
- <Icon
108
+ <IconCmp
98
109
  name={iconName}
99
110
  size={resolvedIconSize}
100
111
  className="kol-icon-default"
101
112
  style={{ position: 'absolute' }}
102
113
  />
103
- <Icon
114
+ <IconCmp
104
115
  name={iconHoverName}
105
116
  size={resolvedIconSize}
106
117
  className="kol-icon-hover"
@@ -117,13 +128,16 @@ const Button = ({
117
128
  return renderIcon(iconOnly, iconOnlyHover)
118
129
  }
119
130
 
120
- // Button with icon(s) and text
131
+ // Button with icon(s) and text. Icons render directly as flex items —
132
+ // wrapper spans (with the old -2px optical margins) sat the glyphs on
133
+ // the span's text baseline instead of letting align-items center them
134
+ // against the label.
121
135
  if (iconLeft || iconRight || iconLeftHover || iconRightHover) {
122
136
  return (
123
137
  <span className="flex items-center" style={{ gap: iconGap ?? 8 }}>
124
- {(iconLeft || iconLeftHover) && <span style={{ marginLeft: -2 }}>{renderIcon(iconLeft, iconLeftHover)}</span>}
138
+ {(iconLeft || iconLeftHover) && renderIcon(iconLeft, iconLeftHover)}
125
139
  {children}
126
- {(iconRight || iconRightHover) && <span style={{ marginRight: -2 }}>{renderIcon(iconRight, iconRightHover)}</span>}
140
+ {(iconRight || iconRightHover) && renderIcon(iconRight, iconRightHover)}
127
141
  </span>
128
142
  )
129
143
  }
@@ -146,7 +160,7 @@ const Button = ({
146
160
  className={combinedClass}
147
161
  style={mergedStyle}
148
162
  disabled={disabled}
149
- aria-pressed={selected ? true : undefined}
163
+ aria-pressed={pressed !== undefined ? pressed : (selected ? true : undefined)}
150
164
  aria-label={iconOnly ? (props['aria-label'] || 'Button') : undefined}
151
165
  {...props}
152
166
  >
@@ -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
+ }
@@ -0,0 +1,180 @@
1
+ import { useId } from 'react'
2
+
3
+ /* ------------------------------------------------------------------ *
4
+ * Curve math — ported from the brand app's curveMath so the primitive
5
+ * has no app import. Curves return a blend in [0..1] for a position
6
+ * `t` in [0..1]. `blend` is the amount knob — for 'flat' it IS the
7
+ * value; for other presets it biases the curve up/down (0.5 = pure
8
+ * curve); for 'custom' it biases the bezier. Consumers with their own
9
+ * math bypass all of this via the `easing` prop.
10
+ * ------------------------------------------------------------------ */
11
+
12
+ const TAU = Math.PI * 2
13
+ const clamp = (v, lo = 0, hi = 1) => (v < lo ? lo : v > hi ? hi : v)
14
+
15
+ /* CSS-style cubic-bezier easing: bisect for the parameter s where the
16
+ * bezier's x(s) ≈ t, then return y(s). 30 iterations = sub-pixel. */
17
+ function cubicBezier(x1, y1, x2, y2, t) {
18
+ if (t <= 0) return 0
19
+ if (t >= 1) return 1
20
+ let lo = 0, hi = 1
21
+ for (let i = 0; i < 30; i++) {
22
+ const s = (lo + hi) / 2
23
+ const oneMs = 1 - s
24
+ const x = 3 * oneMs * oneMs * s * x1 + 3 * oneMs * s * s * x2 + s * s * s
25
+ if (x < t) lo = s
26
+ else hi = s
27
+ }
28
+ const s = (lo + hi) / 2
29
+ const oneMs = 1 - s
30
+ return 3 * oneMs * oneMs * s * y1 + 3 * oneMs * s * s * y2 + s * s * s
31
+ }
32
+
33
+ function curveBlend(t, curve, blend, cp1, cp2) {
34
+ const bias = (blend - 0.5) * 2
35
+ switch (curve) {
36
+ case 'linear': return clamp(t + bias)
37
+ case 'reverse': return clamp((1 - t) + bias)
38
+ case 'ease': return clamp((t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t)) + bias)
39
+ case 'expo-in': return clamp(Math.pow(t, 3) + bias)
40
+ case 'expo-out': return clamp(1 - Math.pow(1 - t, 3) + bias)
41
+ case 'log': return clamp(Math.log(1 + t * 9) / Math.log(10) + bias)
42
+ case 'sine': return 0.5 + 0.5 * Math.sin((t + blend) * TAU)
43
+ case 'custom': return clamp(cubicBezier(cp1.x, cp1.y, cp2.x, cp2.y, t) + bias)
44
+ case 'flat':
45
+ default: return blend
46
+ }
47
+ }
48
+
49
+ const SAMPLES = 48
50
+ /* Retokened from the app's `--ui-warning` accent yellow: the overlay
51
+ * stroke is the theme's editor-accent role, not a warning state. */
52
+ const STROKE = 'var(--kol-accent-primary)'
53
+
54
+ function sampleCurve(width, height, curve, blend, cp1, cp2, easing) {
55
+ const pts = []
56
+ for (let i = 0; i < SAMPLES; i++) {
57
+ const t = i / (SAMPLES - 1)
58
+ const v = clamp(easing ? easing(t) : curveBlend(t, curve, blend, cp1, cp2))
59
+ pts.push({ x: t * width, y: (1 - v) * height })
60
+ }
61
+ return pts
62
+ }
63
+
64
+ /* Draggable control-point dot. The SVG root is pointer-events:none;
65
+ * only these opt back in, so a parent drag system hit-tests them via
66
+ * their stable data-role. Outline is `--kol-surface-primary` (was
67
+ * literal white) so the contrast ring holds in both themes. */
68
+ function Handle({ cx, cy, role }) {
69
+ return (
70
+ <circle
71
+ data-role={role}
72
+ cx={cx}
73
+ cy={cy}
74
+ r="6"
75
+ fill={STROKE}
76
+ stroke="var(--kol-surface-primary)"
77
+ strokeWidth="1.5"
78
+ style={{ cursor: 'grab', pointerEvents: 'auto' }}
79
+ />
80
+ )
81
+ }
82
+
83
+ /**
84
+ * CurveOverlay — SVG easing/curve visualizer laid absolutely over a frame:
85
+ * a dashed accent curve with endpoint dots, and — in `curve="custom"`
86
+ * mode — a two-handle cubic-bezier editor (Figma/After-Effects style)
87
+ * with tangent lines back to the anchored endpoints.
88
+ *
89
+ * Store-free and render-only: the root is pointer-events-none; only the
90
+ * two handles accept pointers, carrying data-role="curve-cp1"/"curve-cp2"
91
+ * so a PARENT drag system picks them up, maps pointer → normalized cp and
92
+ * passes it back down. Coordinates: x = t·width; y is inverted
93
+ * (y = (1-v)·height) so value 0 sits at the bottom, 1 at the top —
94
+ * endpoints are anchored at (0, height) and (width, 0). The svg gets a
95
+ * useId-derived id so per-instance [data-role] queries can be scoped
96
+ * (`#id [data-role=…]` — CSS.escape the id, useId contains colons).
97
+ *
98
+ * Built-in presets are ported curve math ('flat' | 'linear' | 'reverse' |
99
+ * 'ease' | 'expo-in' | 'expo-out' | 'log' | 'sine'); pass `easing` to make
100
+ * the primitive math-agnostic instead.
101
+ *
102
+ * @param {number} width frame width in px — viewBox width, x-scale
103
+ * @param {number} height frame height in px — viewBox height, y-scale
104
+ * @param {string} curve 'custom' → bezier path + handles; else preset name
105
+ * @param {number} blend 0..1 curve amount for presets (0.5 = pure curve)
106
+ * @param {Object} cp1 {x,y} normalized 0..1 first control point (custom)
107
+ * @param {Object} cp2 {x,y} normalized 0..1 second control point (custom)
108
+ * @param {Function} easing (t)=>v sampler overriding the built-in presets
109
+ */
110
+ export default function CurveOverlay({
111
+ width,
112
+ height,
113
+ curve = 'linear',
114
+ blend = 0.5,
115
+ cp1,
116
+ cp2,
117
+ easing,
118
+ }) {
119
+ const id = useId()
120
+ const isCustom = curve === 'custom'
121
+
122
+ /* Endpoints stay anchored; only the two control points slide. */
123
+ const cp1Px = isCustom ? { x: cp1.x * width, y: (1 - cp1.y) * height } : null
124
+ const cp2Px = isCustom ? { x: cp2.x * width, y: (1 - cp2.y) * height } : null
125
+
126
+ const pts = isCustom ? null : sampleCurve(width, height, curve, blend, cp1, cp2, easing)
127
+ const polylinePoints = pts
128
+ ? pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
129
+ : null
130
+
131
+ /* Custom curve: a true cubic path instead of a sampled polyline. */
132
+ const cubicPath = isCustom
133
+ ? `M0 ${height.toFixed(1)} C ${cp1Px.x.toFixed(1)} ${cp1Px.y.toFixed(1)}, ${cp2Px.x.toFixed(1)} ${cp2Px.y.toFixed(1)}, ${width.toFixed(1)} 0`
134
+ : null
135
+
136
+ return (
137
+ <svg
138
+ id={id}
139
+ aria-hidden
140
+ width={width}
141
+ height={height}
142
+ viewBox={`0 0 ${width} ${height}`}
143
+ className="absolute left-0 top-0 overflow-visible pointer-events-none"
144
+ style={{ zIndex: 3 }}
145
+ >
146
+ {isCustom ? (
147
+ <>
148
+ <path
149
+ d={cubicPath}
150
+ fill="none"
151
+ stroke={STROKE}
152
+ strokeWidth="1.5"
153
+ strokeDasharray="4 3"
154
+ opacity="0.85"
155
+ />
156
+ {/* Tangent lines from endpoints to control points. */}
157
+ <line x1="0" y1={height} x2={cp1Px.x} y2={cp1Px.y} stroke={STROKE} strokeWidth="1" opacity="0.45" />
158
+ <line x1={width} y1="0" x2={cp2Px.x} y2={cp2Px.y} stroke={STROKE} strokeWidth="1" opacity="0.45" />
159
+ <circle cx="0" cy={height} r="3" fill={STROKE} opacity="0.85" />
160
+ <circle cx={width} cy="0" r="3" fill={STROKE} opacity="0.85" />
161
+ <Handle cx={cp1Px.x} cy={cp1Px.y} role="curve-cp1" />
162
+ <Handle cx={cp2Px.x} cy={cp2Px.y} role="curve-cp2" />
163
+ </>
164
+ ) : (
165
+ <>
166
+ <polyline
167
+ points={polylinePoints}
168
+ fill="none"
169
+ stroke={STROKE}
170
+ strokeWidth="1.5"
171
+ strokeDasharray="4 3"
172
+ opacity="0.85"
173
+ />
174
+ <circle cx={pts[0].x} cy={pts[0].y} r="3" fill={STROKE} opacity="0.85" />
175
+ <circle cx={pts[pts.length - 1].x} cy={pts[pts.length - 1].y} r="3" fill={STROKE} opacity="0.85" />
176
+ </>
177
+ )}
178
+ </svg>
179
+ )
180
+ }
@@ -0,0 +1,48 @@
1
+ import useScrollSpy from '../hooks/useScrollSpy.js'
2
+
3
+ /* taxonomy-ok: atoms despite being interactive — it composes only the
4
+ * useScrollSpy hook, and hooks don't count as nesting; no KOL component
5
+ * is rendered (nav + anchors only). */
6
+
7
+ /**
8
+ * DocsToc — on-page table of contents for long docs pages: a flat list of
9
+ * anchor links that highlights the heading currently in view. The scroll
10
+ * spy is useScrollSpy (IntersectionObserver + edge lock); this component
11
+ * only renders the nav and maps the active id onto the links.
12
+ *
13
+ * A matching in-page element must exist for every `toc` id — the spy
14
+ * observes `document.getElementById(id)`. Clicking a link sets the browser
15
+ * hash natively; `onNavigate` lets the consumer hook the click (prevent
16
+ * default, smooth-scroll, close a mobile drawer). Labels render verbatim —
17
+ * casing is authored at the call site.
18
+ *
19
+ * @param {Array<{id: string, label: string}>} toc headings to render + observe
20
+ * @param {Function} onNavigate (event) => void — optional click handler on every link
21
+ * @param {string} rootMargin IntersectionObserver rootMargin passed to the
22
+ * spy. Default keeps the ported source's tighter
23
+ * top band (useScrollSpy's own default is
24
+ * '-30% 0px -60% 0px')
25
+ */
26
+ export default function DocsToc({ toc, onNavigate, rootMargin = '-80px 0px -80% 0px' }) {
27
+ const activeId = useScrollSpy(toc.map((item) => item.id), { rootMargin })
28
+
29
+ return (
30
+ <nav>
31
+ <ul className="space-y-0">
32
+ {toc.map((item) => (
33
+ <li key={item.id}>
34
+ <a
35
+ href={`#${item.id}`}
36
+ onClick={onNavigate}
37
+ className={`kol-mono-12 block rounded py-1 transition-colors focus-visible:ring-focus hover:text-fg-96 ${
38
+ activeId === item.id ? 'text-fg-96' : 'text-fg-64'
39
+ }`}
40
+ >
41
+ {item.label}
42
+ </a>
43
+ </li>
44
+ ))}
45
+ </ul>
46
+ </nav>
47
+ )
48
+ }
@@ -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
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * EmptyState — a stacked "nothing here yet / nothing selected" text block
3
+ * for inspectors, empty rails and unshipped panels. Ported from the brand
4
+ * editor's inspector Placeholder (renamed: AssetPlaceholder already owns
5
+ * the placeholder name). All lines render as authored — no auto casing
6
+ * (the source's `uppercase` on the eyebrow was dropped per KOL rules).
7
+ *
8
+ * @param {string} eyebrow kicker line above the title
9
+ * @param {string} title headline
10
+ * @param {string} body optional supporting line
11
+ * @param {string} footer optional note above a top hairline
12
+ */
13
+ export default function EmptyState({ eyebrow, title, body, footer }) {
14
+ return (
15
+ <div>
16
+ {eyebrow && <p className="kol-helper-10 text-meta mb-1">{eyebrow}</p>}
17
+ {title && <p className="kol-helper-16 text-emphasis mb-3">{title}</p>}
18
+ {body && <p className="kol-sans-body-03 text-body mb-4">{body}</p>}
19
+ {footer && <p className="kol-helper-12 text-meta pt-3 border-t border-fg-08">{footer}</p>}
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Figure — the shared caption'd media shell for long-form prose: optional
3
+ * label above, an aspect-locked bordered frame around `children`, optional
4
+ * figcaption below. ImageBlock and VideoBlock (and the portable-text image
5
+ * renderer) all compose this one shell.
6
+ *
7
+ * Aspect is an inline style (not aspect-[x/y]) so any ratio works without
8
+ * Tailwind needing to see the class. Pass aspect="" for natural height.
9
+ *
10
+ * @param {string} label small mono label above the frame
11
+ * @param {string} caption figcaption below the frame
12
+ * @param {string} aspect CSS aspect-ratio value, e.g. '5/3' (default)
13
+ */
14
+ export default function Figure({ label, caption, aspect = '5/3', className = '', children }) {
15
+ return (
16
+ <figure className={`kol-prose-figure ${className}`.trim()}>
17
+ {label && <div className="kol-caption-label">{label}</div>}
18
+ <div
19
+ className="border border-fg-08 rounded overflow-hidden"
20
+ style={aspect ? { aspectRatio: aspect } : undefined}
21
+ >
22
+ {children}
23
+ </div>
24
+ {caption && <figcaption className="kol-caption-text">{caption}</figcaption>}
25
+ </figure>
26
+ )
27
+ }