@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
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.2.0",
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",
@@ -28,11 +29,18 @@
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
+ }
@@ -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"
@@ -149,7 +160,7 @@ const Button = ({
149
160
  className={combinedClass}
150
161
  style={mergedStyle}
151
162
  disabled={disabled}
152
- aria-pressed={selected ? true : undefined}
163
+ aria-pressed={pressed !== undefined ? pressed : (selected ? true : undefined)}
153
164
  aria-label={iconOnly ? (props['aria-label'] || 'Button') : undefined}
154
165
  {...props}
155
166
  >
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import Hls from 'hls.js'
3
+
4
+ /**
5
+ * HlsVideo — background/decorative HLS-streaming video atom. A single native
6
+ * <video>: the effect attaches an .m3u8 stream via hls.js (Safari-native HLS
7
+ * when hls.js isn't supported) and destroys the instance on unmount/src
8
+ * change. Deliberately inert — pointer-events none plus the full hardening
9
+ * attribute set (no controls, PiP, context menu, download, fullscreen, or
10
+ * remote playback) keep it non-interactive on every device; do not strip
11
+ * these without explicit approval. All layout/sizing arrives via `className`
12
+ * (consumer decides `absolute inset-0 object-cover`, etc.).
13
+ *
14
+ * @param {string} src HLS manifest URL (.m3u8); attach effect re-runs on change
15
+ * @param {string} poster native poster frame shown before playback
16
+ * @param {string} className all layout/sizing/positioning (consumer-supplied)
17
+ * @param {Function} onEnded end-of-playback callback; its presence disables `loop`
18
+ */
19
+ export default function HlsVideo({ src, poster, className, onEnded, ...props }) {
20
+ const videoRef = useRef(null)
21
+
22
+ useEffect(() => {
23
+ const video = videoRef.current
24
+ if (!video || !src) return
25
+
26
+ if (Hls.isSupported()) {
27
+ const hls = new Hls()
28
+ hls.loadSource(src)
29
+ hls.attachMedia(video)
30
+ return () => hls.destroy()
31
+ } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
32
+ // Safari native HLS support
33
+ video.src = src
34
+ }
35
+ }, [src])
36
+
37
+ return (
38
+ <video
39
+ ref={videoRef}
40
+ poster={poster}
41
+ className={className}
42
+ style={{ pointerEvents: 'none' }}
43
+ autoPlay
44
+ loop={!onEnded}
45
+ muted
46
+ onEnded={onEnded}
47
+ playsInline
48
+ controls={false}
49
+ disablePictureInPicture
50
+ disableRemotePlayback
51
+ controlsList="nodownload nofullscreen noremoteplayback"
52
+ onContextMenu={(e) => e.preventDefault()}
53
+ {...props}
54
+ />
55
+ )
56
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OverlayGlassPanel — the KOL frosted-glass content card that floats over
3
+ * hero/carousel media: translucent surface-primary (color-mix) + a 1px
4
+ * backdrop blur, children stacked vertically. Extracted from 4 identical
5
+ * inline copies in the monorepo (StudioHero, StudioAboutCard,
6
+ * FeaturedCarousel, TypefacePage). The 80% / 1px defaults ARE the design —
7
+ * exposed as props, not tokenized away.
8
+ *
9
+ * @param {number} surfaceOpacity % of surface-primary in the color-mix
10
+ * @param {string} blur backdrop-filter blur radius
11
+ * @param {string} align 'center' | 'start' | 'end' (cross-axis + text)
12
+ * @param {string} gap vertical rhythm class between children
13
+ * @param {string} maxWidth optional max-w-* class (adds mx-auto)
14
+ */
15
+ export default function OverlayGlassPanel({
16
+ children,
17
+ surfaceOpacity = 80,
18
+ blur = '1px',
19
+ align = 'center',
20
+ gap = 'gap-6',
21
+ maxWidth = '',
22
+ className = '',
23
+ }) {
24
+ const alignCls =
25
+ align === 'start' ? 'items-start text-left'
26
+ : align === 'end' ? 'items-end text-right'
27
+ : 'items-center text-center'
28
+
29
+ return (
30
+ <div
31
+ className={`flex flex-col ${alignCls} ${gap} rounded-[2px] px-6 py-8 ${maxWidth ? `${maxWidth} mx-auto` : ''} ${className}`.trim()}
32
+ style={{
33
+ backgroundColor: `color-mix(in srgb, var(--kol-surface-primary) ${surfaceOpacity}%, transparent)`,
34
+ backdropFilter: `blur(${blur})`,
35
+ }}
36
+ >
37
+ {children}
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * PriceDisplay — baseline-aligned price line: a large primary amount beside a
3
+ * small muted secondary note (a second-currency echo or a cost breakdown).
4
+ * Formats `amount` via Intl.NumberFormat in whole units (prints are priced in
5
+ * round numbers); `secondary` is authored at the call site, parens and all —
6
+ * omit it to render the primary alone.
7
+ *
8
+ * Presentational — pure scalars in, formatted currency out. Seed atom for the
9
+ * price/sale/edition family.
10
+ *
11
+ * @param {number} amount primary price, run through the currency formatter
12
+ * @param {string} currency ISO 4217 code for `amount` (e.g. 'EUR', 'ISK')
13
+ * @param {string} locale number-format locale
14
+ * @param {ReactNode} secondary muted trailing note (omit to hide)
15
+ */
16
+ export default function PriceDisplay({
17
+ amount,
18
+ currency = 'EUR',
19
+ locale = 'de-DE',
20
+ secondary,
21
+ }) {
22
+ const formatted = new Intl.NumberFormat(locale, {
23
+ style: 'currency',
24
+ currency,
25
+ maximumFractionDigits: 0,
26
+ }).format(amount)
27
+
28
+ return (
29
+ <div className="flex flex-wrap items-baseline gap-3">
30
+ <span className="kol-sans-heading-03">{formatted}</span>
31
+ {secondary != null && <span className="kol-mono-12 text-fg-48">{secondary}</span>}
32
+ </div>
33
+ )
34
+ }