@kolkrabbi/kol-component 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -3
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/atoms/Button.jsx +17 -6
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/QuantityInput.jsx +76 -114
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +66 -2
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/MenuPopover.jsx +4 -27
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- package/src/organisms/foundry/index.js +21 -0
- package/src/atoms/QuantityStepper.jsx +0 -149
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
+
}
|
package/src/atoms/Button.jsx
CHANGED
|
@@ -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 <
|
|
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
|
-
<
|
|
108
|
+
<IconCmp
|
|
98
109
|
name={iconName}
|
|
99
110
|
size={resolvedIconSize}
|
|
100
111
|
className="kol-icon-default"
|
|
101
112
|
style={{ position: 'absolute' }}
|
|
102
113
|
/>
|
|
103
|
-
<
|
|
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
|
+
}
|