@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.
- 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/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 -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/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/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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProsePreview — a full rich-text specimen: one `.kol-prose` block exercising
|
|
3
|
+
* every long-form element — H1–H4 ladder, section lede, body paragraph,
|
|
4
|
+
* blockquote, indented passage, code block, pullout, and unordered/ordered
|
|
5
|
+
* lists — so the KOL prose stylesheet can be reviewed end-to-end in one view.
|
|
6
|
+
* The "long-form" member of the type-specimen kit.
|
|
7
|
+
*
|
|
8
|
+
* Fixed structure; only the text slots vary. All element styling comes from
|
|
9
|
+
* the prose stylesheet (kol-typography.css) plus the kit's
|
|
10
|
+
* `.kol-prose-indented` / `.kol-prose-pullout` chrome — none lives here.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} h1 trailing text of the H1 (rendered as `H1 — {h1}`)
|
|
13
|
+
* @param {string} paragraph body paragraph text
|
|
14
|
+
* @param {string} code contents of the <pre><code> block
|
|
15
|
+
* @param {string} pullout trailing text of the pullout (rendered as `Pullout — {pullout}`)
|
|
16
|
+
* @param {string} quote blockquote text
|
|
17
|
+
*/
|
|
18
|
+
export default function ProsePreview({ h1, paragraph, code, pullout, quote = 'Above as below — justice is a starfield.' }) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="kol-prose">
|
|
21
|
+
<h1>H1 — {h1}</h1>
|
|
22
|
+
<h2>H2 — Section title</h2>
|
|
23
|
+
<h3>H3 — Subsection</h3>
|
|
24
|
+
<h4>H4 — Block title</h4>
|
|
25
|
+
<p className="kol-sans-body-01 text-strong mb-6">
|
|
26
|
+
Section lede — a larger paragraph used to introduce a section and
|
|
27
|
+
establish tone before the body copy begins.
|
|
28
|
+
</p>
|
|
29
|
+
<p>{paragraph}</p>
|
|
30
|
+
<blockquote><p>{quote}</p></blockquote>
|
|
31
|
+
<div className="kol-prose-indented">
|
|
32
|
+
<p>
|
|
33
|
+
Indented passage. Used for tangential copy, contextual notes, or
|
|
34
|
+
nested argument that supports the main line.
|
|
35
|
+
</p>
|
|
36
|
+
</div>
|
|
37
|
+
<pre className="bg-fg-04 border border-fg-08"><code>{code}</code></pre>
|
|
38
|
+
<p className="kol-prose-pullout kol-helper-12 tracking-widest text-body">
|
|
39
|
+
Pullout — {pullout}
|
|
40
|
+
</p>
|
|
41
|
+
<ul>
|
|
42
|
+
<li>Unordered list — item one</li>
|
|
43
|
+
<li>Item two</li>
|
|
44
|
+
<li>Item three</li>
|
|
45
|
+
</ul>
|
|
46
|
+
<ol>
|
|
47
|
+
<li>Ordered list — item one</li>
|
|
48
|
+
<li>Item two</li>
|
|
49
|
+
<li>Item three</li>
|
|
50
|
+
</ol>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Clamp to min–max, snapped to the nearest step from min. */
|
|
4
|
+
const snapTo = (v, min, max, step) => {
|
|
5
|
+
const stepped = min + Math.round((v - min) / step) * step
|
|
6
|
+
return Math.max(min, Math.min(max, stepped))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RotaryDial — drag-to-set rotary knob numeric input. Vertical pointer drag
|
|
11
|
+
* rotates the whole dial over a 270° sweep (−135° to +135°) mapped onto
|
|
12
|
+
* min–max; a full sweep is ~200px of drag, movement measured absolute from
|
|
13
|
+
* drag start. Controlled (`value`/`onChange`) with a local visual buffer so
|
|
14
|
+
* rotation stays smooth while parent updates are RAF-throttled; the final
|
|
15
|
+
* value fires on release. Focus + arrow keys step the value
|
|
16
|
+
* (role="slider" with aria-valuemin/max/now). The label and `%` readout
|
|
17
|
+
* rows render only when `label` is set; label text renders as authored.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} label text under the dial; also gates the label + readout rows
|
|
20
|
+
* @param {number} value controlled value within min–max
|
|
21
|
+
* @param {Function} onChange (next: number) => void — RAF-throttled while dragging, final on release
|
|
22
|
+
* @param {number} min minimum value (default 0)
|
|
23
|
+
* @param {number} max maximum value (default 100)
|
|
24
|
+
* @param {number} step drag snap / arrow-key increment (default 1)
|
|
25
|
+
* @param {number} size dial px size (default 80); derives ring + disc radii
|
|
26
|
+
*/
|
|
27
|
+
export default function RotaryDial({
|
|
28
|
+
label,
|
|
29
|
+
value = 0,
|
|
30
|
+
onChange,
|
|
31
|
+
min = 0,
|
|
32
|
+
max = 100,
|
|
33
|
+
step = 1,
|
|
34
|
+
size = 80,
|
|
35
|
+
}) {
|
|
36
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
37
|
+
const [localValue, setLocalValue] = useState(value) // visual buffer — updates every move
|
|
38
|
+
const dragRef = useRef({ startY: 0, startValue: 0 })
|
|
39
|
+
const rafRef = useRef(null)
|
|
40
|
+
const onChangeRef = useRef(onChange)
|
|
41
|
+
onChangeRef.current = onChange
|
|
42
|
+
|
|
43
|
+
// External updates land whenever a drag isn't in progress
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!isDragging) setLocalValue(value)
|
|
46
|
+
}, [value, isDragging])
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!isDragging) return
|
|
50
|
+
let current = dragRef.current.startValue
|
|
51
|
+
const handleMove = (e) => {
|
|
52
|
+
// absolute delta from drag start; full min→max sweep ≈ 200px
|
|
53
|
+
const deltaY = dragRef.current.startY - e.clientY
|
|
54
|
+
current = snapTo(dragRef.current.startValue + deltaY * ((max - min) / 200), min, max, step)
|
|
55
|
+
setLocalValue(current)
|
|
56
|
+
if (!rafRef.current) {
|
|
57
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
58
|
+
onChangeRef.current?.(current)
|
|
59
|
+
rafRef.current = null
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const handleUp = () => {
|
|
64
|
+
setIsDragging(false)
|
|
65
|
+
if (rafRef.current) {
|
|
66
|
+
cancelAnimationFrame(rafRef.current)
|
|
67
|
+
rafRef.current = null
|
|
68
|
+
}
|
|
69
|
+
onChangeRef.current?.(current)
|
|
70
|
+
}
|
|
71
|
+
window.addEventListener('pointermove', handleMove)
|
|
72
|
+
window.addEventListener('pointerup', handleUp)
|
|
73
|
+
window.addEventListener('pointercancel', handleUp)
|
|
74
|
+
return () => {
|
|
75
|
+
window.removeEventListener('pointermove', handleMove)
|
|
76
|
+
window.removeEventListener('pointerup', handleUp)
|
|
77
|
+
window.removeEventListener('pointercancel', handleUp)
|
|
78
|
+
}
|
|
79
|
+
}, [isDragging, min, max, step])
|
|
80
|
+
|
|
81
|
+
const handlePointerDown = (e) => {
|
|
82
|
+
setIsDragging(true)
|
|
83
|
+
dragRef.current = { startY: e.clientY, startValue: localValue }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const handleKeyDown = (e) => {
|
|
87
|
+
let next = null
|
|
88
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next = snapTo(localValue + step, min, max, step)
|
|
89
|
+
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next = snapTo(localValue - step, min, max, step)
|
|
90
|
+
if (next === null) return
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
setLocalValue(next)
|
|
93
|
+
onChange?.(next)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const outerRadius = size / 2
|
|
97
|
+
const innerRadius = (size * 0.7) / 2
|
|
98
|
+
const strokeWidth = 2
|
|
99
|
+
const angle = -135 + ((localValue - min) / (max - min || 1)) * 270
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="flex flex-col items-center gap-2">
|
|
103
|
+
<div
|
|
104
|
+
role="slider"
|
|
105
|
+
tabIndex={0}
|
|
106
|
+
aria-label={label}
|
|
107
|
+
aria-valuemin={min}
|
|
108
|
+
aria-valuemax={max}
|
|
109
|
+
aria-valuenow={localValue}
|
|
110
|
+
className="relative cursor-pointer select-none touch-none"
|
|
111
|
+
style={{
|
|
112
|
+
width: size,
|
|
113
|
+
height: size,
|
|
114
|
+
transform: `rotate(${angle}deg)`,
|
|
115
|
+
willChange: isDragging ? 'transform' : 'auto',
|
|
116
|
+
}}
|
|
117
|
+
onPointerDown={handlePointerDown}
|
|
118
|
+
onKeyDown={handleKeyDown}
|
|
119
|
+
>
|
|
120
|
+
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: 'visible' }}>
|
|
121
|
+
{/* outer dashed tick ring */}
|
|
122
|
+
<circle
|
|
123
|
+
cx={outerRadius}
|
|
124
|
+
cy={outerRadius}
|
|
125
|
+
r={outerRadius - strokeWidth}
|
|
126
|
+
fill="none"
|
|
127
|
+
stroke="currentColor"
|
|
128
|
+
strokeWidth={strokeWidth}
|
|
129
|
+
strokeDasharray="4 4"
|
|
130
|
+
className="text-fg-24"
|
|
131
|
+
/>
|
|
132
|
+
{/* inner solid disc */}
|
|
133
|
+
<circle cx={outerRadius} cy={outerRadius} r={innerRadius} fill="currentColor" className="text-fg-96" />
|
|
134
|
+
{/* pointer notch — reads as a cut into the disc */}
|
|
135
|
+
<line
|
|
136
|
+
x1={outerRadius}
|
|
137
|
+
y1={outerRadius}
|
|
138
|
+
x2={outerRadius}
|
|
139
|
+
y2={outerRadius - innerRadius + 4}
|
|
140
|
+
stroke="var(--kol-surface-primary)"
|
|
141
|
+
strokeWidth={2}
|
|
142
|
+
strokeLinecap="round"
|
|
143
|
+
/>
|
|
144
|
+
</svg>
|
|
145
|
+
</div>
|
|
146
|
+
{label && <div className="kol-helper-12 text-fg-64">{label}</div>}
|
|
147
|
+
{label && <div className="kol-helper-12 text-fg-96">{value}%</div>}
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SearchInput — controlled search field on the .kol-control shell. The
|
|
5
|
+
* search-flavored sibling of Input (same variant/size chrome + inner-input
|
|
6
|
+
* treatment), standalone because atoms can't nest atoms. Leading search
|
|
7
|
+
* icon, then two mutually exclusive trailing affordances: a caller-authored
|
|
8
|
+
* shortcut kbd chip while the value is empty, a clear × while it isn't.
|
|
9
|
+
*
|
|
10
|
+
* Always controlled (`value` defaults to ''). Controlled + no onChange =
|
|
11
|
+
* deliberate display-only → readOnly, same convention as Input. Extra props
|
|
12
|
+
* (onKeyDown, autoFocus, role, aria-*) spread onto the inner <input> —
|
|
13
|
+
* ShellSearchOverlay drives its combobox wiring through that seam.
|
|
14
|
+
*
|
|
15
|
+
* `type="search"` for semantics (searchbox role, mobile "search" enter key);
|
|
16
|
+
* the native WebKit cancel button is hidden so the × stays the only clear
|
|
17
|
+
* affordance.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} value controlled value
|
|
20
|
+
* @param {Function} onChange (event) => void — input change
|
|
21
|
+
* @param {string} placeholder placeholder text
|
|
22
|
+
* @param {Function} onClear () => void — trailing × click; × renders only when set and value is non-empty
|
|
23
|
+
* @param {string} shortcutHint caller-authored kbd chip (e.g. "⌘K"); shown while the value is empty
|
|
24
|
+
* @param {Function} onFocus (event) => void — input focus
|
|
25
|
+
* @param {string} size 'sm' | 'md' — kol-control size + matched mono type class
|
|
26
|
+
* @param {string} variant 'filled' | 'ghost' | 'outline' — kol-control variant, same chrome as Input (ignored when bare)
|
|
27
|
+
* @param {boolean} bare borderless inline field for overlay panels (full width, no shell chrome; keeps icon/clear/chip slots)
|
|
28
|
+
* @param {string} className extra classes on the shell
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14' }
|
|
32
|
+
const ICON_SIZE = { sm: 14, md: 14 }
|
|
33
|
+
|
|
34
|
+
export default function SearchInput({
|
|
35
|
+
value = '',
|
|
36
|
+
onChange,
|
|
37
|
+
placeholder = 'Search…',
|
|
38
|
+
onClear,
|
|
39
|
+
shortcutHint,
|
|
40
|
+
onFocus,
|
|
41
|
+
size = 'md',
|
|
42
|
+
variant = 'filled',
|
|
43
|
+
bare = false,
|
|
44
|
+
className = '',
|
|
45
|
+
...inputProps
|
|
46
|
+
}) {
|
|
47
|
+
const shellCls = [
|
|
48
|
+
bare
|
|
49
|
+
? 'flex w-full gap-2.5 px-4 py-3'
|
|
50
|
+
: `kol-control kol-control--${variant} kol-control-${size} gap-2`,
|
|
51
|
+
'items-center cursor-text',
|
|
52
|
+
SIZE_TYPE[size],
|
|
53
|
+
className,
|
|
54
|
+
].filter(Boolean).join(' ')
|
|
55
|
+
|
|
56
|
+
/* Same height pin as Input: Chromium sizes an <input> from font metrics,
|
|
57
|
+
* not CSS line-height, so without this the shell lands ~0.5px tall.
|
|
58
|
+
* h-4 / h-[18px] match the kol-mono-12 / -14 line-heights. */
|
|
59
|
+
const heightCls = size === 'sm' ? 'h-4' : 'h-[18px]'
|
|
60
|
+
|
|
61
|
+
const inputCls = [
|
|
62
|
+
'min-w-0 flex-1 bg-transparent border-none outline-none text-auto',
|
|
63
|
+
'appearance-none [&::-webkit-search-cancel-button]:hidden',
|
|
64
|
+
heightCls,
|
|
65
|
+
].join(' ')
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<label className={shellCls}>
|
|
69
|
+
<span
|
|
70
|
+
aria-hidden="true"
|
|
71
|
+
className={`flex items-center shrink-0 ${bare ? 'text-fg-48' : 'text-auto opacity-50'}`}
|
|
72
|
+
>
|
|
73
|
+
<Icon name="search" size={ICON_SIZE[size] ?? 14} />
|
|
74
|
+
</span>
|
|
75
|
+
<input
|
|
76
|
+
type="search"
|
|
77
|
+
value={value ?? ''}
|
|
78
|
+
onChange={onChange}
|
|
79
|
+
readOnly={!onChange || undefined}
|
|
80
|
+
onFocus={onFocus}
|
|
81
|
+
placeholder={placeholder}
|
|
82
|
+
spellCheck={false}
|
|
83
|
+
className={inputCls}
|
|
84
|
+
{...inputProps}
|
|
85
|
+
/>
|
|
86
|
+
{onClear && value ? (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
aria-label="Clear"
|
|
90
|
+
/* preventDefault on mousedown keeps focus in the input across the clear */
|
|
91
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
92
|
+
onClick={onClear}
|
|
93
|
+
className="inline-flex items-center justify-center shrink-0 cursor-pointer text-fg-48 hover:text-fg-96 transition-colors"
|
|
94
|
+
>
|
|
95
|
+
<Icon name="x" size={12} />
|
|
96
|
+
</button>
|
|
97
|
+
) : shortcutHint ? (
|
|
98
|
+
/* aria-hidden — affordance, not a label (same stance as Input's prefix/suffix) */
|
|
99
|
+
<kbd
|
|
100
|
+
aria-hidden="true"
|
|
101
|
+
className="inline-flex items-center justify-center shrink-0 h-4 min-w-4 px-1 rounded-[3px] bg-fg-08 kol-helper-10 text-fg-48"
|
|
102
|
+
>
|
|
103
|
+
{shortcutHint}
|
|
104
|
+
</kbd>
|
|
105
|
+
) : null}
|
|
106
|
+
</label>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
3
|
+
|
|
4
|
+
// Variable-font axis rest values — where every glyph sits with no pointer near.
|
|
5
|
+
const DEFAULTS = { wdth: 100, wght: 400, ital: 0, alpha: 1 }
|
|
6
|
+
const STATIC_VARIATION = "'wght' 400, 'wdth' 100, 'ital' 0.00"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TextPressure — a single line of variable-font text whose glyphs deform
|
|
10
|
+
* toward the pointer: characters nearest the cursor grow widest / heaviest /
|
|
11
|
+
* most italic, and the effect falls off linearly with distance. A
|
|
12
|
+
* requestAnimationFrame loop lerps a smoothed follower toward the raw cursor
|
|
13
|
+
* (container-scoped listeners) and writes per-glyph `font-variation-settings`
|
|
14
|
+
* each frame. On mouse-leave the glyphs coast back to their rest axis values
|
|
15
|
+
* (`returnToDefault`) or freeze in place, then the loop auto-stops until the
|
|
16
|
+
* pointer returns. Ported from the react-bits / JuanFuentes effect, collapsed
|
|
17
|
+
* from three source variants into one.
|
|
18
|
+
*
|
|
19
|
+
* ── Font contract ──────────────────────────────────────────────────────────
|
|
20
|
+
* The effect only *moves* if the resolved font is a VARIABLE font exposing the
|
|
21
|
+
* `wght` / `wdth` / `ital` axes. The KOL variable font is a CONSUMER asset —
|
|
22
|
+
* the design system does not ship one. Two ways to supply it:
|
|
23
|
+
* • `fontUrl` given → an `@font-face` is injected at runtime for `fontFamily`
|
|
24
|
+
* (e.g. fontFamily="TG Root-Tune" fontUrl="/fonts/TGRoot-TuneVF.ttf").
|
|
25
|
+
* • `fontUrl` omitted → the already-loaded `fontFamily` is used as-is; the
|
|
26
|
+
* default `var(--kol-font-family-sans)` animates only if that theme font is
|
|
27
|
+
* itself variable — otherwise the text renders correctly but static.
|
|
28
|
+
*
|
|
29
|
+
* Reduced motion → no listeners, no rAF; glyphs render static at the rest axis
|
|
30
|
+
* values (wght 400 / wdth 100 / ital 0). Casing is authored by the caller —
|
|
31
|
+
* this component never text-transforms.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text the line; split into one <span> per character
|
|
34
|
+
* @param {string} fontFamily applied family + injected @font-face family
|
|
35
|
+
* @param {string} [fontUrl] variable-font URL → injects @font-face when set
|
|
36
|
+
* @param {boolean} width animate the `wdth` axis (5–150)
|
|
37
|
+
* @param {boolean} weight animate the `wght` axis (100–900)
|
|
38
|
+
* @param {boolean} italic animate the `ital` axis (0–1)
|
|
39
|
+
* @param {boolean} alpha animate per-glyph opacity (0–1)
|
|
40
|
+
* @param {boolean} flex distribute glyphs with justify-between
|
|
41
|
+
* @param {boolean} stroke add an outlined ghost layer behind each glyph
|
|
42
|
+
* @param {boolean} scale vertically scale the line to fill the container
|
|
43
|
+
* @param {string} textColor glyph color (KOL token / CSS color)
|
|
44
|
+
* @param {string} strokeColor stroke color when `stroke` is on
|
|
45
|
+
* @param {string} className extra classes on the <h1>
|
|
46
|
+
* @param {number} minFontSize floor for the responsive font size
|
|
47
|
+
* @param {number} [maxFontSize] cap for the responsive font size
|
|
48
|
+
* @param {boolean} returnToDefault true: coast back to rest on leave; false: freeze
|
|
49
|
+
*/
|
|
50
|
+
export default function TextPressure({
|
|
51
|
+
text = 'Compressa',
|
|
52
|
+
fontFamily = 'var(--kol-font-family-sans)',
|
|
53
|
+
fontUrl,
|
|
54
|
+
|
|
55
|
+
width = true,
|
|
56
|
+
weight = true,
|
|
57
|
+
italic = true,
|
|
58
|
+
alpha = false,
|
|
59
|
+
|
|
60
|
+
flex = true,
|
|
61
|
+
stroke = false,
|
|
62
|
+
scale = false,
|
|
63
|
+
|
|
64
|
+
textColor = 'var(--kol-fg-emphasis)',
|
|
65
|
+
strokeColor = 'var(--kol-accent-primary)',
|
|
66
|
+
className = '',
|
|
67
|
+
|
|
68
|
+
minFontSize = 24,
|
|
69
|
+
maxFontSize = null,
|
|
70
|
+
returnToDefault = true,
|
|
71
|
+
}) {
|
|
72
|
+
const containerRef = useRef(null)
|
|
73
|
+
const titleRef = useRef(null)
|
|
74
|
+
const spansRef = useRef([])
|
|
75
|
+
|
|
76
|
+
const mouseRef = useRef({ x: 0, y: 0 })
|
|
77
|
+
const cursorRef = useRef({ x: 0, y: 0 })
|
|
78
|
+
const currentValuesRef = useRef([])
|
|
79
|
+
const velocityRef = useRef([])
|
|
80
|
+
|
|
81
|
+
const [fontSize, setFontSize] = useState(minFontSize)
|
|
82
|
+
const [scaleY, setScaleY] = useState(1)
|
|
83
|
+
const [lineHeight, setLineHeight] = useState(1)
|
|
84
|
+
const [isHovering, setIsHovering] = useState(false)
|
|
85
|
+
|
|
86
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
87
|
+
const chars = text.split('')
|
|
88
|
+
|
|
89
|
+
const dist = (a, b) => {
|
|
90
|
+
const dx = b.x - a.x
|
|
91
|
+
const dy = b.y - a.y
|
|
92
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Container-scoped pointer tracking (never window). Skipped for reduced motion.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (reducedMotion) return undefined
|
|
98
|
+
const container = containerRef.current
|
|
99
|
+
if (!container) return undefined
|
|
100
|
+
|
|
101
|
+
const handleMouseMove = (e) => {
|
|
102
|
+
cursorRef.current.x = e.clientX
|
|
103
|
+
cursorRef.current.y = e.clientY
|
|
104
|
+
}
|
|
105
|
+
const handleTouchMove = (e) => {
|
|
106
|
+
const t = e.touches[0]
|
|
107
|
+
cursorRef.current.x = t.clientX
|
|
108
|
+
cursorRef.current.y = t.clientY
|
|
109
|
+
}
|
|
110
|
+
const handleMouseEnter = () => setIsHovering(true)
|
|
111
|
+
const handleMouseLeave = () => setIsHovering(false)
|
|
112
|
+
|
|
113
|
+
container.addEventListener('mousemove', handleMouseMove)
|
|
114
|
+
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
115
|
+
container.addEventListener('mouseenter', handleMouseEnter)
|
|
116
|
+
container.addEventListener('mouseleave', handleMouseLeave)
|
|
117
|
+
|
|
118
|
+
const { left, top, width: w, height: h } = container.getBoundingClientRect()
|
|
119
|
+
mouseRef.current.x = left + w / 2
|
|
120
|
+
mouseRef.current.y = top + h / 2
|
|
121
|
+
cursorRef.current.x = mouseRef.current.x
|
|
122
|
+
cursorRef.current.y = mouseRef.current.y
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
container.removeEventListener('mousemove', handleMouseMove)
|
|
126
|
+
container.removeEventListener('touchmove', handleTouchMove)
|
|
127
|
+
container.removeEventListener('mouseenter', handleMouseEnter)
|
|
128
|
+
container.removeEventListener('mouseleave', handleMouseLeave)
|
|
129
|
+
}
|
|
130
|
+
}, [reducedMotion])
|
|
131
|
+
|
|
132
|
+
// Responsive sizing (layout, not motion — runs regardless of reduced motion).
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const setSize = () => {
|
|
135
|
+
if (!containerRef.current || !titleRef.current) return
|
|
136
|
+
const { width: containerW, height: containerH } = containerRef.current.getBoundingClientRect()
|
|
137
|
+
|
|
138
|
+
let newFontSize = containerW / (chars.length / 2)
|
|
139
|
+
newFontSize = Math.max(newFontSize, minFontSize)
|
|
140
|
+
if (maxFontSize) newFontSize = Math.min(newFontSize, maxFontSize)
|
|
141
|
+
|
|
142
|
+
setFontSize(newFontSize)
|
|
143
|
+
setScaleY(1)
|
|
144
|
+
setLineHeight(1)
|
|
145
|
+
|
|
146
|
+
requestAnimationFrame(() => {
|
|
147
|
+
if (!titleRef.current) return
|
|
148
|
+
const textRect = titleRef.current.getBoundingClientRect()
|
|
149
|
+
if (scale && textRect.height > 0) {
|
|
150
|
+
const yRatio = containerH / textRect.height
|
|
151
|
+
setScaleY(yRatio)
|
|
152
|
+
setLineHeight(yRatio)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
setSize()
|
|
158
|
+
window.addEventListener('resize', setSize)
|
|
159
|
+
return () => window.removeEventListener('resize', setSize)
|
|
160
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
|
+
}, [scale, text, minFontSize, maxFontSize])
|
|
162
|
+
|
|
163
|
+
// The pressure loop. Skipped entirely for reduced motion (glyphs stay static).
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (reducedMotion) return undefined
|
|
166
|
+
let rafId
|
|
167
|
+
|
|
168
|
+
const animate = () => {
|
|
169
|
+
if (isHovering) {
|
|
170
|
+
mouseRef.current.x += (cursorRef.current.x - mouseRef.current.x) / 15
|
|
171
|
+
mouseRef.current.y += (cursorRef.current.y - mouseRef.current.y) / 15
|
|
172
|
+
|
|
173
|
+
if (titleRef.current && containerRef.current) {
|
|
174
|
+
const containerRect = containerRef.current.getBoundingClientRect()
|
|
175
|
+
const maxDist = containerRect.width / 2
|
|
176
|
+
|
|
177
|
+
spansRef.current.forEach((span, i) => {
|
|
178
|
+
if (!span) return
|
|
179
|
+
const rect = span.getBoundingClientRect()
|
|
180
|
+
const charCenter = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
|
181
|
+
const d = dist(mouseRef.current, charCenter)
|
|
182
|
+
|
|
183
|
+
const getAttr = (distance, minVal, maxVal) => {
|
|
184
|
+
const val = maxVal - Math.abs((maxVal * distance) / maxDist)
|
|
185
|
+
return Math.max(minVal, val + minVal)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const wdth = width ? Math.floor(getAttr(d, 5, 150)) : 100
|
|
189
|
+
const wght = weight ? Math.floor(getAttr(d, 100, 900)) : 400
|
|
190
|
+
const italVal = italic ? getAttr(d, 0, 1) : 0
|
|
191
|
+
const alphaVal = alpha ? getAttr(d, 0, 1) : 1
|
|
192
|
+
|
|
193
|
+
const prev = currentValuesRef.current[i]
|
|
194
|
+
if (prev && !returnToDefault) {
|
|
195
|
+
velocityRef.current[i] = {
|
|
196
|
+
wdth: wdth - prev.wdth,
|
|
197
|
+
wght: wght - prev.wght,
|
|
198
|
+
ital: italVal - prev.ital,
|
|
199
|
+
alpha: alphaVal - prev.alpha,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
currentValuesRef.current[i] = { wdth, wght, ital: italVal, alpha: alphaVal }
|
|
203
|
+
|
|
204
|
+
span.style.opacity = alphaVal
|
|
205
|
+
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal.toFixed(2)}`
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// Coast back to rest (returnToDefault) or settle into a frozen pose.
|
|
210
|
+
let allSettled = true
|
|
211
|
+
|
|
212
|
+
spansRef.current.forEach((span, i) => {
|
|
213
|
+
if (!span) return
|
|
214
|
+
const current = currentValuesRef.current[i] || { ...DEFAULTS }
|
|
215
|
+
if (!velocityRef.current[i]) velocityRef.current[i] = { wdth: 0, wght: 0, ital: 0, alpha: 0 }
|
|
216
|
+
const velocity = velocityRef.current[i]
|
|
217
|
+
|
|
218
|
+
if (returnToDefault) {
|
|
219
|
+
const springStrength = 0.05
|
|
220
|
+
const damping = 0.88
|
|
221
|
+
velocity.wdth += (DEFAULTS.wdth - current.wdth) * springStrength
|
|
222
|
+
velocity.wght += (DEFAULTS.wght - current.wght) * springStrength
|
|
223
|
+
velocity.ital += (DEFAULTS.ital - current.ital) * springStrength
|
|
224
|
+
velocity.alpha += (DEFAULTS.alpha - current.alpha) * springStrength
|
|
225
|
+
velocity.wdth *= damping
|
|
226
|
+
velocity.wght *= damping
|
|
227
|
+
velocity.ital *= damping
|
|
228
|
+
velocity.alpha *= damping
|
|
229
|
+
|
|
230
|
+
const newWdth = current.wdth + velocity.wdth
|
|
231
|
+
const newWght = current.wght + velocity.wght
|
|
232
|
+
const newItal = current.ital + velocity.ital
|
|
233
|
+
const newAlpha = current.alpha + velocity.alpha
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
Math.abs(newWdth - DEFAULTS.wdth) > 0.5 ||
|
|
237
|
+
Math.abs(newWght - DEFAULTS.wght) > 0.5 ||
|
|
238
|
+
Math.abs(velocity.wdth) > 0.1 ||
|
|
239
|
+
Math.abs(velocity.wght) > 0.1
|
|
240
|
+
) {
|
|
241
|
+
allSettled = false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
currentValuesRef.current[i] = { wdth: newWdth, wght: newWght, ital: newItal, alpha: newAlpha }
|
|
245
|
+
span.style.opacity = newAlpha
|
|
246
|
+
span.style.fontVariationSettings = `'wght' ${Math.round(newWght)}, 'wdth' ${Math.round(newWdth)}, 'ital' ${newItal.toFixed(2)}`
|
|
247
|
+
} else {
|
|
248
|
+
const damping = 0.75
|
|
249
|
+
velocity.wdth *= damping
|
|
250
|
+
velocity.wght *= damping
|
|
251
|
+
velocity.ital *= damping
|
|
252
|
+
velocity.alpha *= damping
|
|
253
|
+
|
|
254
|
+
const newWdth = current.wdth + velocity.wdth
|
|
255
|
+
const newWght = current.wght + velocity.wght
|
|
256
|
+
const newItal = current.ital + velocity.ital
|
|
257
|
+
const newAlpha = current.alpha + velocity.alpha
|
|
258
|
+
|
|
259
|
+
if (
|
|
260
|
+
Math.abs(velocity.wdth) > 0.01 ||
|
|
261
|
+
Math.abs(velocity.wght) > 0.01 ||
|
|
262
|
+
Math.abs(velocity.ital) > 0.0001 ||
|
|
263
|
+
Math.abs(velocity.alpha) > 0.0001
|
|
264
|
+
) {
|
|
265
|
+
allSettled = false
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
currentValuesRef.current[i] = { wdth: newWdth, wght: newWght, ital: newItal, alpha: newAlpha }
|
|
269
|
+
span.style.opacity = newAlpha
|
|
270
|
+
span.style.fontVariationSettings = `'wght' ${Math.round(newWght)}, 'wdth' ${Math.round(newWdth)}, 'ital' ${newItal.toFixed(2)}`
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
if (allSettled) {
|
|
275
|
+
cancelAnimationFrame(rafId)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
rafId = requestAnimationFrame(animate)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
animate()
|
|
284
|
+
return () => cancelAnimationFrame(rafId)
|
|
285
|
+
}, [width, weight, italic, alpha, chars.length, isHovering, returnToDefault, reducedMotion])
|
|
286
|
+
|
|
287
|
+
const titleClass = [
|
|
288
|
+
'kol-text-pressure-title m-0 w-full max-w-full overflow-hidden box-border text-center whitespace-nowrap select-none origin-top font-thin',
|
|
289
|
+
flex ? 'flex justify-between' : '',
|
|
290
|
+
stroke ? 'kol-text-pressure--stroke' : '',
|
|
291
|
+
className,
|
|
292
|
+
]
|
|
293
|
+
.filter(Boolean)
|
|
294
|
+
.join(' ')
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div
|
|
298
|
+
ref={containerRef}
|
|
299
|
+
className="kol-text-pressure relative flex h-full w-full max-w-full items-center justify-center overflow-hidden"
|
|
300
|
+
>
|
|
301
|
+
{fontUrl && (
|
|
302
|
+
<style>{`@font-face { font-family: '${fontFamily}'; src: url('${fontUrl}'); font-style: normal; }`}</style>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
<h1
|
|
306
|
+
ref={titleRef}
|
|
307
|
+
className={titleClass}
|
|
308
|
+
style={{
|
|
309
|
+
fontFamily,
|
|
310
|
+
fontSize,
|
|
311
|
+
lineHeight,
|
|
312
|
+
transform: `scale(1, ${scaleY})`,
|
|
313
|
+
color: textColor,
|
|
314
|
+
'--kol-text-pressure-stroke': strokeColor,
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{chars.map((char, i) => (
|
|
318
|
+
<span
|
|
319
|
+
key={i}
|
|
320
|
+
ref={(el) => (spansRef.current[i] = el)}
|
|
321
|
+
data-char={char}
|
|
322
|
+
className="kol-text-pressure-char inline-block"
|
|
323
|
+
style={{ fontVariationSettings: STATIC_VARIATION }}
|
|
324
|
+
>
|
|
325
|
+
{char}
|
|
326
|
+
</span>
|
|
327
|
+
))}
|
|
328
|
+
</h1>
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|