@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.
- package/package.json +12 -4
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
- package/src/atoms/Avatar.jsx +4 -4
- package/src/atoms/Button.jsx +23 -9
- package/src/atoms/CopyButton.jsx +50 -0
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/Input.jsx +7 -2
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/{molecules → atoms}/SegmentedToggle.jsx +33 -14
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/ToggleCheckbox.jsx +1 -1
- package/src/atoms/ToggleSwitch.jsx +1 -1
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
- package/src/graphics/Graphic.jsx +56 -18
- package/src/graphics/graphicData.js +19 -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 +119 -39
- package/src/{primitives → molecules}/Accordion.jsx +4 -1
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/CodeBlock.jsx +11 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
- package/src/molecules/Dropdown.jsx +3 -2
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/{primitives → molecules}/Image.jsx +1 -1
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/MediaCard.jsx +97 -0
- package/src/molecules/MediaRow.jsx +49 -0
- package/src/molecules/MenuItem.jsx +3 -2
- package/src/molecules/MenuPopover.jsx +11 -96
- 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/{atoms → molecules}/Slider.jsx +1 -1
- 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/{molecules → organisms}/ContentFilters.jsx +2 -2
- 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/primitives/CodeBlock.jsx +0 -41
- /package/src/{molecules → atoms}/Badge.jsx +0 -0
- /package/src/{primitives → atoms}/ExitPreview.jsx +0 -0
- /package/src/{primitives → atoms}/FullscreenOverlay.jsx +0 -0
- /package/src/{molecules → atoms}/LabeledControl.jsx +0 -0
- /package/src/{molecules → atoms}/Pill.jsx +0 -0
- /package/src/{molecules → atoms}/Popover.jsx +0 -0
- /package/src/{molecules → atoms}/QuantityInput.jsx +0 -0
- /package/src/{molecules → atoms}/QuantityStepper.jsx +0 -0
- /package/src/{molecules → atoms}/Section.jsx +0 -0
- /package/src/{molecules → atoms}/SectionLabel.jsx +0 -0
- /package/src/{molecules → atoms}/Tag.jsx +0 -0
- /package/src/{molecules → atoms}/ToggleBracket.jsx +0 -0
- /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.
|
|
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.
|
|
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
|
-
|
|
25
|
-
|
|
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
|
}
|
package/src/atoms/Avatar.jsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const SIZE_MAP = {
|
|
2
|
-
sm: 'w-8 h-8
|
|
3
|
-
md: 'w-10 h-10
|
|
4
|
-
lg: 'w-14 h-14
|
|
5
|
-
xl: 'w-24 h-24
|
|
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 = '' }) {
|
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"
|
|
@@ -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) &&
|
|
138
|
+
{(iconLeft || iconLeftHover) && renderIcon(iconLeft, iconLeftHover)}
|
|
125
139
|
{children}
|
|
126
|
-
{(iconRight || iconRightHover) &&
|
|
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(
|
|
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
|
+
}
|