@kolkrabbi/kol-component 0.6.0 → 0.8.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/README.md +8 -0
- package/package.json +2 -3
- package/src/atoms/RotaryDial.jsx +35 -21
- package/src/index.js +6 -18
- package/src/molecules/ButtonGroup.jsx +45 -0
- package/src/molecules/ColorInputRow.jsx +146 -126
- package/src/molecules/Slider.jsx +29 -14
- package/src/molecules/SplitToolButton.jsx +133 -0
- package/src/organisms/ContentFilters.jsx +2 -2
- package/src/organisms/GalleryCarousel.jsx +2 -1
- package/src/organisms/LoaderOverlay.jsx +11 -14
- package/src/organisms/MediaTileGallery.jsx +57 -0
- package/src/organisms/MediaViewer.jsx +89 -64
- package/src/atoms/PriceDisplay.jsx +0 -34
- package/src/atoms/TextPressure.jsx +0 -331
- package/src/atoms/TypeSample.jsx +0 -50
- package/src/atoms/TypeSpecCard.jsx +0 -42
- package/src/molecules/ArticleCard.jsx +0 -178
- package/src/molecules/WorkListItem.jsx +0 -83
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
- package/src/organisms/ArticleHeader.jsx +0 -95
- package/src/organisms/ColorLoader.jsx +0 -155
- package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
- package/src/organisms/ParallaxShelf.jsx +0 -141
- package/src/organisms/PortableTextRenderer.jsx +0 -115
- package/src/organisms/ProductDetailLayout.jsx +0 -189
- package/src/organisms/ScrollDriftGallery.jsx +0 -214
- package/src/organisms/StackHero.jsx +0 -83
- package/src/organisms/WorkCard.jsx +0 -120
- package/src/organisms/WorkViewToggle.jsx +0 -170
- package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
- package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
- package/src/organisms/foundry/TypefaceHero.jsx +0 -107
- package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
- package/src/organisms/foundry/VariableFontSection.jsx +0 -158
- package/src/organisms/foundry/glyphData.js +0 -30
- package/src/organisms/foundry/index.js +0 -21
|
@@ -1,331 +0,0 @@
|
|
|
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
|
-
}
|
package/src/atoms/TypeSample.jsx
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TypeSample — a single labeled type-specimen block: an optional mono caption
|
|
3
|
-
* over one paragraph whose typography is driven entirely by props via inline
|
|
4
|
-
* style. The atomic unit of the type-specimen kit — stack several to show a
|
|
5
|
-
* scale, a weight range, or a family; adjacent samples get a hairline
|
|
6
|
-
* separator (`.kol-type-sample + .kol-type-sample`).
|
|
7
|
-
*
|
|
8
|
-
* Presentational — arbitrary px values are the point, so props map to inline
|
|
9
|
-
* style rather than fixed kol-* size stops. Label and children render
|
|
10
|
-
* verbatim as authored.
|
|
11
|
-
*
|
|
12
|
-
* @param {string} family font family name (wrapped as `"family", sans-serif`); defaults to the KOL sans token
|
|
13
|
-
* @param {number} weight font-weight
|
|
14
|
-
* @param {boolean} italic italic sample
|
|
15
|
-
* @param {number} size font-size in px
|
|
16
|
-
* @param {number} lineHeight line-height in px; unitless 1.2 when unset
|
|
17
|
-
* @param {string} label mono caption above the sample (omit to hide)
|
|
18
|
-
* @param {ReactNode} children the specimen text itself
|
|
19
|
-
*/
|
|
20
|
-
export default function TypeSample({
|
|
21
|
-
family,
|
|
22
|
-
weight = 400,
|
|
23
|
-
italic = false,
|
|
24
|
-
size = 32,
|
|
25
|
-
lineHeight,
|
|
26
|
-
label,
|
|
27
|
-
children,
|
|
28
|
-
}) {
|
|
29
|
-
return (
|
|
30
|
-
<div className="kol-type-sample py-6">
|
|
31
|
-
{label && (
|
|
32
|
-
<p className="kol-helper-12 tracking-wider text-meta m-0 mb-3">
|
|
33
|
-
{label}
|
|
34
|
-
</p>
|
|
35
|
-
)}
|
|
36
|
-
<p
|
|
37
|
-
className="kol-type-sample-body m-0 text-auto"
|
|
38
|
-
style={{
|
|
39
|
-
fontFamily: family ? `"${family}", sans-serif` : 'var(--kol-font-family-sans)',
|
|
40
|
-
fontWeight: weight,
|
|
41
|
-
fontStyle: italic ? 'italic' : 'normal',
|
|
42
|
-
fontSize: `${size}px`,
|
|
43
|
-
lineHeight: lineHeight ? `${lineHeight}px` : '1.2',
|
|
44
|
-
}}
|
|
45
|
-
>
|
|
46
|
-
{children}
|
|
47
|
-
</p>
|
|
48
|
-
</div>
|
|
49
|
-
)
|
|
50
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TypeSpecCard — a two-column type-spec row: a left meta panel of key/value
|
|
3
|
-
* pairs (font metrics) beside a live sample slot on the right, with an
|
|
4
|
-
* optional corner label. The "data-sheet" member of the type-specimen kit —
|
|
5
|
-
* pairs the numeric spec of a type style with a rendered example of it
|
|
6
|
-
* (often a TypeSample passed as children, composed at the call site).
|
|
7
|
-
*
|
|
8
|
-
* Single column on mobile; `240px + fluid` two-column at lg. `meta` is
|
|
9
|
-
* arbitrary tuples — no fixed metric schema. Sample paragraphs get 16px
|
|
10
|
-
* rhythm via `.kol-type-spec-sample p` (Preflight zeroes <p> margins).
|
|
11
|
-
*
|
|
12
|
-
* @param {string} label corner caption, absolute top-left (omit to hide)
|
|
13
|
-
* @param {Array<[string, ReactNode]>} meta key/value rows in the left panel
|
|
14
|
-
* @param {ReactNode} children the live type sample on the right
|
|
15
|
-
*/
|
|
16
|
-
export default function TypeSpecCard({ label, meta = [], children }) {
|
|
17
|
-
return (
|
|
18
|
-
<div className="kol-type-spec relative py-12 border-t border-fg-08">
|
|
19
|
-
{label && (
|
|
20
|
-
<span className="kol-type-spec-label kol-helper-12 tracking-widest text-meta absolute top-4 left-0">
|
|
21
|
-
{label}
|
|
22
|
-
</span>
|
|
23
|
-
)}
|
|
24
|
-
<div className="kol-type-spec-row grid grid-cols-1 gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-12 items-start pt-6">
|
|
25
|
-
<div className="kol-type-spec-meta flex flex-col">
|
|
26
|
-
{meta.map(([key, value]) => (
|
|
27
|
-
<div
|
|
28
|
-
key={key}
|
|
29
|
-
className="kol-type-spec-meta-row grid grid-cols-[auto_minmax(0,1fr)] gap-4 items-baseline py-2 border-b border-[var(--kol-fg-04)] last:border-b-0"
|
|
30
|
-
>
|
|
31
|
-
<span className="kol-helper-10 text-meta">{key}</span>
|
|
32
|
-
<span className="kol-helper-10 text-strong text-right [overflow-wrap:anywhere]">{value}</span>
|
|
33
|
-
</div>
|
|
34
|
-
))}
|
|
35
|
-
</div>
|
|
36
|
-
<div className="kol-type-spec-sample min-w-0">
|
|
37
|
-
{children}
|
|
38
|
-
</div>
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
)
|
|
42
|
-
}
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import Pill from '../atoms/Pill.jsx'
|
|
2
|
-
import Image from './Image.jsx'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Optional link wrapper — the one place routing lives. `http*`/`mailto` hrefs
|
|
6
|
-
* open in a new tab; any other href renders a plain same-tab anchor with an
|
|
7
|
-
* `onNavigate(event)` seam an SPA consumer intercepts (preventDefault + its
|
|
8
|
-
* router). No `href` → the bare content, no anchor.
|
|
9
|
-
*/
|
|
10
|
-
function CardLink({ href, onNavigate, className, children }) {
|
|
11
|
-
if (!href) return <div className={className}>{children}</div>
|
|
12
|
-
const isExternal = href.startsWith('http') || href.startsWith('mailto')
|
|
13
|
-
return (
|
|
14
|
-
<a
|
|
15
|
-
href={href}
|
|
16
|
-
onClick={isExternal ? undefined : onNavigate}
|
|
17
|
-
target={isExternal ? '_blank' : undefined}
|
|
18
|
-
rel={isExternal ? 'noreferrer noopener' : undefined}
|
|
19
|
-
className={className}
|
|
20
|
-
>
|
|
21
|
-
{children}
|
|
22
|
-
</a>
|
|
23
|
-
)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Cover-fit thumbnail (or fg-token placeholder box when no src). */
|
|
27
|
-
function Thumb({ src, alt }) {
|
|
28
|
-
if (!src) return null
|
|
29
|
-
return (
|
|
30
|
-
<Image
|
|
31
|
-
src={src}
|
|
32
|
-
alt={alt}
|
|
33
|
-
className="object-cover"
|
|
34
|
-
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
35
|
-
/>
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* ArticleCard — one blog-card family with three sizes (`default` / `hero` /
|
|
41
|
-
* `mini`), collapsing what were three byte-identical duplicate components. All
|
|
42
|
-
* three share the flat view-model (title, excerpt, thumbnail, tags, meta) and
|
|
43
|
-
* the link seam; `size` picks the layout.
|
|
44
|
-
*
|
|
45
|
-
* - **default** — grid tile: thumbnail (landscape/portrait) over tag Pills, a
|
|
46
|
-
* mono title, an excerpt, and a `date • readingTime` meta Pill.
|
|
47
|
-
* - **hero** — index masthead: an optional header row (`label` + `meta`),
|
|
48
|
-
* a big 16/9 image that zooms on hover, then kicker → title → summary.
|
|
49
|
-
* - **mini** — sidebar row: a fixed 120×120 thumbnail beside title → summary →
|
|
50
|
-
* meta.
|
|
51
|
-
*
|
|
52
|
-
* De-Sanitized flat props, no router import. Placeholder thumbnails use
|
|
53
|
-
* fg-opacity tokens (theme-aware — no `.dark` style injection). Every string
|
|
54
|
-
* is authored at the call site in its final case — no `text-transform`, no JS
|
|
55
|
-
* casing (the kicker/meta `uppercase` are presentational classes only).
|
|
56
|
-
*
|
|
57
|
-
* @param {'default'|'hero'|'mini'} size layout preset (default 'default')
|
|
58
|
-
* @param {string} title card title
|
|
59
|
-
* @param {string} excerpt summary/excerpt paragraph (alias: `summary`)
|
|
60
|
-
* @param {string} summary alias for `excerpt` (hero/mini call it this)
|
|
61
|
-
* @param {string} kicker hero-only eyebrow above the title
|
|
62
|
-
* @param {string} label hero header label (was the hardcoded "Featured")
|
|
63
|
-
* @param {string[]} meta hero header chips / mini meta line (joined ` • `)
|
|
64
|
-
* @param {string} date default meta — left of the `•`
|
|
65
|
-
* @param {string} readingTime default meta — right of the `•`
|
|
66
|
-
* @param {string[]} tags default: rendered as Pills; hero/mini: `data-tags` only
|
|
67
|
-
* @param {string} thumbnail image src (omit → fg-token placeholder)
|
|
68
|
-
* @param {'landscape'|'portrait'} aspect default thumbnail ratio (default 'landscape')
|
|
69
|
-
* @param {boolean} showHeader hero: show the label/meta header row (default true)
|
|
70
|
-
* @param {string} href link target; `http*`/`mailto` → new tab, else same-tab seam
|
|
71
|
-
* @param {Function} onNavigate (event) => void — same-tab click seam (SPA intercept)
|
|
72
|
-
* @param {string} className extra classes on the root
|
|
73
|
-
*/
|
|
74
|
-
export default function ArticleCard({
|
|
75
|
-
size = 'default',
|
|
76
|
-
title,
|
|
77
|
-
excerpt,
|
|
78
|
-
summary,
|
|
79
|
-
kicker,
|
|
80
|
-
label,
|
|
81
|
-
meta,
|
|
82
|
-
date,
|
|
83
|
-
readingTime,
|
|
84
|
-
tags = [],
|
|
85
|
-
thumbnail,
|
|
86
|
-
aspect = 'landscape',
|
|
87
|
-
showHeader = true,
|
|
88
|
-
href,
|
|
89
|
-
onNavigate,
|
|
90
|
-
className = '',
|
|
91
|
-
}) {
|
|
92
|
-
const body = excerpt ?? summary
|
|
93
|
-
const dataTags = tags?.length ? tags.join(' ') : undefined
|
|
94
|
-
|
|
95
|
-
if (size === 'hero') {
|
|
96
|
-
return (
|
|
97
|
-
<CardLink href={href} onNavigate={onNavigate} className={`block group ${className}`.trim()}>
|
|
98
|
-
<article className="w-full" data-tags={dataTags}>
|
|
99
|
-
{showHeader && (label || meta?.length) && (
|
|
100
|
-
<div className="flex justify-between items-center mb-4">
|
|
101
|
-
{label && <div className="kol-helper-14 text-fg-64">{label}</div>}
|
|
102
|
-
{meta?.length > 0 && (
|
|
103
|
-
<div className="flex gap-3 kol-helper-12 text-fg-48">
|
|
104
|
-
{meta.map((item, i) => <span key={i}>{item}</span>)}
|
|
105
|
-
</div>
|
|
106
|
-
)}
|
|
107
|
-
</div>
|
|
108
|
-
)}
|
|
109
|
-
<div className="aspect-[16/9] mb-4 overflow-hidden w-full bg-fg-04 border border-fg-08 hover:border-fg-16 rounded">
|
|
110
|
-
{thumbnail && (
|
|
111
|
-
<Image
|
|
112
|
-
src={thumbnail}
|
|
113
|
-
alt={title}
|
|
114
|
-
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
115
|
-
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
116
|
-
/>
|
|
117
|
-
)}
|
|
118
|
-
</div>
|
|
119
|
-
<div className="space-y-3">
|
|
120
|
-
{kicker && (
|
|
121
|
-
<div className="kol-helper-16 uppercase tracking-wide text-fg-64">{kicker}</div>
|
|
122
|
-
)}
|
|
123
|
-
<h2 className="kol-sans-heading-03 transition-opacity duration-200 group-hover:opacity-70 line-clamp-2">
|
|
124
|
-
{title}
|
|
125
|
-
</h2>
|
|
126
|
-
{body && <p className="kol-mono-14 text-fg-48 line-clamp-3">{body}</p>}
|
|
127
|
-
</div>
|
|
128
|
-
</article>
|
|
129
|
-
</CardLink>
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (size === 'mini') {
|
|
134
|
-
const metaText = Array.isArray(meta) ? meta.join(' • ') : meta
|
|
135
|
-
return (
|
|
136
|
-
<CardLink
|
|
137
|
-
href={href}
|
|
138
|
-
onNavigate={onNavigate}
|
|
139
|
-
className={`group flex gap-6 items-start transition-opacity hover:opacity-80 ${className}`.trim()}
|
|
140
|
-
>
|
|
141
|
-
<div className="flex-shrink-0 w-[120px] h-[120px] overflow-hidden rounded bg-fg-12" data-tags={dataTags}>
|
|
142
|
-
<Thumb src={thumbnail} alt={title} />
|
|
143
|
-
</div>
|
|
144
|
-
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
|
145
|
-
<h4 className="kol-mono-14 line-clamp-2 transition-opacity group-hover:opacity-80">{title}</h4>
|
|
146
|
-
{body && <p className="kol-mono-14 text-fg-64 line-clamp-2">{body}</p>}
|
|
147
|
-
{metaText && <div className="kol-helper-12 text-fg-80 uppercase">{metaText}</div>}
|
|
148
|
-
</div>
|
|
149
|
-
</CardLink>
|
|
150
|
-
)
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// size === 'default'
|
|
154
|
-
return (
|
|
155
|
-
<CardLink href={href} onNavigate={onNavigate} className={`block group cursor-pointer w-full max-w-full ${className}`.trim()}>
|
|
156
|
-
<article className="w-full max-w-full" data-tags={dataTags}>
|
|
157
|
-
<div
|
|
158
|
-
className="mb-4 overflow-hidden w-full rounded bg-fg-04 border border-fg-08"
|
|
159
|
-
style={{ aspectRatio: aspect === 'portrait' ? '3/4' : '16/9' }}
|
|
160
|
-
>
|
|
161
|
-
<Thumb src={thumbnail} alt={title} />
|
|
162
|
-
</div>
|
|
163
|
-
{tags.length > 0 && (
|
|
164
|
-
<div className="flex flex-wrap gap-2 mb-2">
|
|
165
|
-
{tags.map((tag, i) => (
|
|
166
|
-
<Pill key={i} variant="inverse" size="sm">{tag}</Pill>
|
|
167
|
-
))}
|
|
168
|
-
</div>
|
|
169
|
-
)}
|
|
170
|
-
<h3 className="mb-2 kol-mono-20 transition-opacity duration-200 group-hover:opacity-70">{title}</h3>
|
|
171
|
-
{body && <p className="mb-2 kol-mono-14 text-fg-64">{body}</p>}
|
|
172
|
-
{(date || readingTime) && (
|
|
173
|
-
<Pill variant="subtle" size="sm">{[date, readingTime].filter(Boolean).join(' • ')}</Pill>
|
|
174
|
-
)}
|
|
175
|
-
</article>
|
|
176
|
-
</CardLink>
|
|
177
|
-
)
|
|
178
|
-
}
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import Image from './Image.jsx'
|
|
2
|
-
import Tag from '../atoms/Tag.jsx'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* WorkListItem — the list/row view of a `/work` project: the row twin of
|
|
6
|
-
* WorkCard. A horizontal card — a small square thumbnail on the left, then a
|
|
7
|
-
* content column split into a header row (title + tag chips on the left, type +
|
|
8
|
-
* year right-aligned) above a large display preview line (the project
|
|
9
|
-
* description). Hover raises a border; `active` persists that treatment for the
|
|
10
|
-
* last-hovered row so a parent list can mark one active.
|
|
11
|
-
*
|
|
12
|
-
* Shares WorkCard's flat project bag so a `/work` view can toggle grid ⇄ list
|
|
13
|
-
* as a render swap, not a data reshape. The whole row is an anchor (`href`) —
|
|
14
|
-
* the source row was not a link (its parent list owned navigation); as the
|
|
15
|
-
* list-view counterpart to WorkCard it is the click target itself. Thumbnail
|
|
16
|
-
* routes through DS Image (graceful missing-asset fallback); tags render as DS
|
|
17
|
-
* Tag chips. Router-agnostic via `onNavigate(href, event)`.
|
|
18
|
-
*
|
|
19
|
-
* Text casing: no text-transform (KOL rule) — the source's `uppercase` title
|
|
20
|
-
* and `capitalize` type are dropped; strings render verbatim as authored.
|
|
21
|
-
*
|
|
22
|
-
* @param {string} title header title
|
|
23
|
-
* @param {string} thumbnail thumbnail URL; the block is omitted when absent
|
|
24
|
-
* @param {string[]} tags rendered as Tag chips under the title
|
|
25
|
-
* @param {string} type right-column label
|
|
26
|
-
* @param {string|number} year right-column, below type
|
|
27
|
-
* @param {string} description large display preview line
|
|
28
|
-
* @param {string} href row navigation target
|
|
29
|
-
* @param {boolean} active persist the hover treatment (last-hovered row)
|
|
30
|
-
* @param {Function} onMouseEnter hover callback (parent tracks the active row)
|
|
31
|
-
* @param {Function} onNavigate (href, event) => void — anchor click seam for SPA routing
|
|
32
|
-
*/
|
|
33
|
-
export default function WorkListItem({
|
|
34
|
-
title,
|
|
35
|
-
thumbnail,
|
|
36
|
-
tags,
|
|
37
|
-
type,
|
|
38
|
-
year,
|
|
39
|
-
description,
|
|
40
|
-
href,
|
|
41
|
-
active = false,
|
|
42
|
-
onMouseEnter,
|
|
43
|
-
onNavigate,
|
|
44
|
-
}) {
|
|
45
|
-
return (
|
|
46
|
-
<a
|
|
47
|
-
href={href}
|
|
48
|
-
onClick={onNavigate ? (e) => onNavigate(href, e) : undefined}
|
|
49
|
-
onMouseEnter={onMouseEnter}
|
|
50
|
-
className={`self-stretch min-h-24 md:min-h-40 p-4 md:p-6 rounded flex items-stretch gap-4 md:gap-6 mb-4 md:mb-6 overflow-hidden cursor-pointer transition-all duration-300 bg-surface-secondary border ${active ? 'border-fg-16' : 'border-transparent hover:border-fg-16'}`}
|
|
51
|
-
>
|
|
52
|
-
{thumbnail && (
|
|
53
|
-
<div className="shrink-0 w-16 h-16 md:w-28 md:h-28 rounded-[2px] overflow-hidden border border-fg-08">
|
|
54
|
-
<Image src={thumbnail} alt={title} category="work" name={title} className="w-full h-full object-cover" />
|
|
55
|
-
</div>
|
|
56
|
-
)}
|
|
57
|
-
|
|
58
|
-
<div className="flex flex-col justify-between min-w-0 flex-1 gap-3 md:gap-4">
|
|
59
|
-
<div className="flex justify-between items-start md:items-center gap-4">
|
|
60
|
-
<div className="flex flex-col gap-1 md:gap-2 min-w-0">
|
|
61
|
-
<div className="kol-mono-14 truncate">{title}</div>
|
|
62
|
-
{tags?.length > 0 && (
|
|
63
|
-
<div className="flex flex-wrap gap-1.5">
|
|
64
|
-
{tags.map((t) => (
|
|
65
|
-
<Tag key={t} size="sm" variant="naked" hash={false}>{t}</Tag>
|
|
66
|
-
))}
|
|
67
|
-
</div>
|
|
68
|
-
)}
|
|
69
|
-
</div>
|
|
70
|
-
|
|
71
|
-
<div className="flex flex-col items-end gap-1 md:gap-2 shrink-0">
|
|
72
|
-
<span className="kol-mono-12 md:kol-mono-14">{type}</span>
|
|
73
|
-
<span className="kol-mono-12 text-fg-64">{year}</span>
|
|
74
|
-
</div>
|
|
75
|
-
</div>
|
|
76
|
-
|
|
77
|
-
<p className="kol-sans-heading-03 text-auto leading-tight whitespace-nowrap overflow-hidden text-ellipsis">
|
|
78
|
-
{description}
|
|
79
|
-
</p>
|
|
80
|
-
</div>
|
|
81
|
-
</a>
|
|
82
|
-
)
|
|
83
|
-
}
|