@kolkrabbi/kol-foundry 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +5 -4
- package/src/ColorLoader.jsx +155 -0
- package/src/TextPressure.jsx +331 -0
- package/src/TypeSample.jsx +50 -0
- package/src/TypeSpecCard.jsx +42 -0
- package/src/TypeSpecimenLive.jsx +130 -0
- package/src/index.js +12 -0
- package/src/useFontMetrics.js +251 -0
package/README.md
CHANGED
|
@@ -13,6 +13,10 @@ The KOL **type-foundry system** — the type-specimen apparatus, lifted out of `
|
|
|
13
13
|
| `TypefaceStyleSection` | per-style specimen block |
|
|
14
14
|
| `FontPreviewSection` | size-ladder / preview control |
|
|
15
15
|
| `SpecimenSectionHeader` | shared section header |
|
|
16
|
+
| `TypeSample` | labeled live type-specimen block (props-driven) |
|
|
17
|
+
| `TypeSpecCard` | font-metric data sheet + live sample slot |
|
|
18
|
+
| `TextPressure` | variable-font glyphs deforming toward the pointer |
|
|
19
|
+
| `ColorLoader` | branded loading curtain with a live variable-font wordmark |
|
|
16
20
|
| `glyphSets`, `glyphCategories`, `SPECIMEN_SAMPLE_TEXT` | glyph data |
|
|
17
21
|
|
|
18
22
|
```js
|
|
@@ -21,4 +25,4 @@ import { TypefaceHero, VariableFontSection } from '@kolkrabbi/kol-foundry'
|
|
|
21
25
|
|
|
22
26
|
## Requirements
|
|
23
27
|
|
|
24
|
-
Components take flat prop bags — typeface metrics / font files are consumer-supplied. Shared primitives (`Tag`, `Pill`, `Slider`, `Button`, `Divider`, `Dropdown`, `useAxisAnimation`) stay in `@kolkrabbi/kol-component`; this package depends on them. Styling comes from `@kolkrabbi/kol-theme`. Vite + Tailwind v4 consumer (`@source "…/node_modules/@kolkrabbi/kol-foundry/src"`).
|
|
28
|
+
Components take flat prop bags — typeface metrics / font files are consumer-supplied. Shared primitives (`Tag`, `Pill`, `Slider`, `Button`, `Divider`, `Dropdown`, `useAxisAnimation`, `usePrefersReducedMotion`) stay in `@kolkrabbi/kol-component`; this package depends on them. `framer-motion` is a peer (`ColorLoader`). Styling comes from `@kolkrabbi/kol-theme`. Vite + Tailwind v4 consumer (`@source "…/node_modules/@kolkrabbi/kol-foundry/src"` — Tailwind skips `node_modules` when scanning, so without this line the specimen layouts never generate).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "KOL foundry component set — the type-specimen apparatus: typeface hero, variable-font axis playground, parsed-metric glyph inspector, character-set browser, font-preview, and the typeface-catalog grid. Every component renders, inspects, or manipulates a live font. Typeface data is consumer-injected. Sits above @kolkrabbi/kol-{theme,icons,component}.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
".": "./src/index.js"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@kolkrabbi/kol-
|
|
15
|
-
"@kolkrabbi/kol-
|
|
16
|
-
"@kolkrabbi/kol-
|
|
14
|
+
"@kolkrabbi/kol-component": "0.9.0",
|
|
15
|
+
"@kolkrabbi/kol-icons": "0.5.0",
|
|
16
|
+
"@kolkrabbi/kol-theme": "0.7.4"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
|
20
20
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
21
|
+
"framer-motion": "^12.0.0",
|
|
21
22
|
"opentype.js": "^1.3.4"
|
|
22
23
|
},
|
|
23
24
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { motion as Motion, useAnimationControls } from 'framer-motion'
|
|
2
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { usePrefersReducedMotion } from '@kolkrabbi/kol-component'
|
|
4
|
+
import TextPressure from './TextPressure.jsx'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ColorLoader — a full-height branded intro/loading curtain. Fills its parent
|
|
8
|
+
* on a solid backdrop, times in a variable-font wordmark (a live TextPressure
|
|
9
|
+
* effect) after `wordmarkDelay`, then reveals a bouncing down-chevron cue after
|
|
10
|
+
* `scrollDelay`. With `dismissOnClick`, clicking the curtain slides the whole
|
|
11
|
+
* thing up out of view and fires `onComplete`; otherwise the parent owns
|
|
12
|
+
* dismissal (unmount when its work is done). Generalized from the app's
|
|
13
|
+
* KOLKRABBI loader: the cursor trail, `cursor: none`, and window-scroll/keyboard
|
|
14
|
+
* gesture hijack are all dropped — exit is a plain click or parent-driven.
|
|
15
|
+
*
|
|
16
|
+
* Reduced motion → no timers, no tweens: one static frame (wordmark + cue shown
|
|
17
|
+
* at rest) and `onComplete` fires immediately on mount, so the curtain never
|
|
18
|
+
* makes a reduced-motion user wait.
|
|
19
|
+
*
|
|
20
|
+
* The wordmark needs a variable font — see TextPressure's font contract. Pass
|
|
21
|
+
* `fontFamily` / `fontUrl` to point at the KOL variable font (a consumer asset).
|
|
22
|
+
*
|
|
23
|
+
* @param {string} text wordmark string fed to TextPressure
|
|
24
|
+
* @param {string} fontFamily wordmark font family (→ TextPressure)
|
|
25
|
+
* @param {string} [fontUrl] variable-font URL → TextPressure @font-face
|
|
26
|
+
* @param {() => void} onComplete fired after the exit slide-up (or at once, reduced)
|
|
27
|
+
* @param {boolean} dismissOnClick click the curtain to slide up + complete
|
|
28
|
+
* @param {string} bgColor backdrop color (KOL token / CSS color)
|
|
29
|
+
* @param {string} markColor wordmark + chevron color (KOL token / CSS color)
|
|
30
|
+
* @param {number} wordmarkDelay ms before the wordmark fades in
|
|
31
|
+
* @param {number} scrollDelay ms before the chevron cue reveals
|
|
32
|
+
* @param {number} exitDuration seconds for the slide-up exit tween
|
|
33
|
+
*/
|
|
34
|
+
export default function ColorLoader({
|
|
35
|
+
text = 'KOLKRABBI',
|
|
36
|
+
fontFamily = 'var(--kol-font-family-sans)',
|
|
37
|
+
fontUrl,
|
|
38
|
+
onComplete,
|
|
39
|
+
dismissOnClick = false,
|
|
40
|
+
bgColor = 'var(--kol-surface-primary)',
|
|
41
|
+
markColor = 'var(--kol-fg-emphasis)',
|
|
42
|
+
wordmarkDelay = 1000,
|
|
43
|
+
scrollDelay = 2000,
|
|
44
|
+
exitDuration = 1.8,
|
|
45
|
+
}) {
|
|
46
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
47
|
+
const [showMark, setShowMark] = useState(reducedMotion)
|
|
48
|
+
const [showCue, setShowCue] = useState(reducedMotion)
|
|
49
|
+
const [exiting, setExiting] = useState(false)
|
|
50
|
+
const controls = useAnimationControls()
|
|
51
|
+
const firedRef = useRef(false)
|
|
52
|
+
|
|
53
|
+
const fireComplete = () => {
|
|
54
|
+
if (firedRef.current) return
|
|
55
|
+
firedRef.current = true
|
|
56
|
+
onComplete?.()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (reducedMotion) {
|
|
61
|
+
// Skip the whole sequence: static frame, signal done at once.
|
|
62
|
+
setShowMark(true)
|
|
63
|
+
setShowCue(true)
|
|
64
|
+
fireComplete()
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
const markTimer = setTimeout(() => setShowMark(true), wordmarkDelay)
|
|
68
|
+
const cueTimer = setTimeout(() => setShowCue(true), scrollDelay)
|
|
69
|
+
return () => {
|
|
70
|
+
clearTimeout(markTimer)
|
|
71
|
+
clearTimeout(cueTimer)
|
|
72
|
+
}
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [reducedMotion, wordmarkDelay, scrollDelay])
|
|
75
|
+
|
|
76
|
+
const dismiss = () => {
|
|
77
|
+
if (exiting) return
|
|
78
|
+
setExiting(true)
|
|
79
|
+
if (reducedMotion) {
|
|
80
|
+
fireComplete()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
controls
|
|
84
|
+
.start({ y: '-100%', transition: { duration: exitDuration, ease: 'easeInOut' } })
|
|
85
|
+
.then(fireComplete)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Motion.div
|
|
90
|
+
className={`flex h-full w-full flex-col ${dismissOnClick ? 'cursor-pointer' : ''}`}
|
|
91
|
+
style={{ backgroundColor: bgColor }}
|
|
92
|
+
animate={controls}
|
|
93
|
+
initial={{ y: 0 }}
|
|
94
|
+
onClick={dismissOnClick ? dismiss : undefined}
|
|
95
|
+
>
|
|
96
|
+
{/* Top third — spacer */}
|
|
97
|
+
<div className="flex flex-1 items-end justify-center self-stretch" />
|
|
98
|
+
|
|
99
|
+
{/* Middle third — wordmark + cue */}
|
|
100
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-12 self-stretch">
|
|
101
|
+
<Motion.div
|
|
102
|
+
initial={{ opacity: 0 }}
|
|
103
|
+
animate={{ opacity: showMark ? 1 : 0 }}
|
|
104
|
+
transition={{ duration: reducedMotion ? 0 : 0.5 }}
|
|
105
|
+
className="w-full px-6"
|
|
106
|
+
>
|
|
107
|
+
<div className="mx-auto h-20 w-full max-w-[280px] sm:h-24 sm:max-w-[400px] md:h-32 md:max-w-[600px]">
|
|
108
|
+
<TextPressure
|
|
109
|
+
text={text}
|
|
110
|
+
fontFamily={fontFamily}
|
|
111
|
+
fontUrl={fontUrl}
|
|
112
|
+
textColor={markColor}
|
|
113
|
+
flex
|
|
114
|
+
width
|
|
115
|
+
weight
|
|
116
|
+
italic={false}
|
|
117
|
+
minFontSize={40}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
</Motion.div>
|
|
121
|
+
|
|
122
|
+
<Motion.div
|
|
123
|
+
initial={{ opacity: 0, y: 0 }}
|
|
124
|
+
animate={{
|
|
125
|
+
opacity: showCue ? 0.6 : 0,
|
|
126
|
+
y: showCue && !reducedMotion ? [0, 12, 0] : 0,
|
|
127
|
+
}}
|
|
128
|
+
transition={{
|
|
129
|
+
opacity: { duration: reducedMotion ? 0 : 0.5 },
|
|
130
|
+
y: reducedMotion ? { duration: 0 } : { duration: 1.5, repeat: Infinity, ease: 'easeInOut' },
|
|
131
|
+
}}
|
|
132
|
+
className="mt-12"
|
|
133
|
+
style={{ color: markColor }}
|
|
134
|
+
>
|
|
135
|
+
<svg
|
|
136
|
+
width="32"
|
|
137
|
+
height="32"
|
|
138
|
+
viewBox="0 0 24 24"
|
|
139
|
+
fill="none"
|
|
140
|
+
stroke="currentColor"
|
|
141
|
+
strokeWidth="2"
|
|
142
|
+
strokeLinecap="round"
|
|
143
|
+
strokeLinejoin="round"
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
>
|
|
146
|
+
<polyline points="6 9 12 15 18 9" />
|
|
147
|
+
</svg>
|
|
148
|
+
</Motion.div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Bottom third — spacer */}
|
|
152
|
+
<div className="flex flex-1 items-end justify-center self-stretch" />
|
|
153
|
+
</Motion.div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { usePrefersReducedMotion } from '@kolkrabbi/kol-component'
|
|
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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/* taxonomy-ok: organism — self-measuring live specimen; owns getComputedStyle
|
|
4
|
+
* I/O over rendered font samples. */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TypeSpecimenLive — a self-measuring type specimen. One row per type class,
|
|
8
|
+
* each rendering a live sample and reading its OWN `getComputedStyle` to print
|
|
9
|
+
* the resolved typeface / weight / size / leading / tracking beside it. Because
|
|
10
|
+
* the labels are measured off the real rendered element, they can never drift
|
|
11
|
+
* from the CSS: retune a token and the specimen follows.
|
|
12
|
+
*
|
|
13
|
+
* Ported from the kol portfolio-kit's `TypeSpecimen` page and generalised for
|
|
14
|
+
* the design system — the kit's hardcoded row list is now the consumer-injected
|
|
15
|
+
* `specs` prop, and the PP-Right-Grotesk / JetBrains-Mono weight-name maps are
|
|
16
|
+
* overridable props (defaulted to the kit's fonts). Casing is untouched: class
|
|
17
|
+
* names and samples render verbatim.
|
|
18
|
+
*
|
|
19
|
+
* WHY IT EXISTS: `TypeSpecCard` is a STATIC data sheet — the consumer types the
|
|
20
|
+
* metric tuples in by hand next to a sample. This component is its live cousin:
|
|
21
|
+
* it MEASURES the sample, so there's nothing to type and nothing to keep in
|
|
22
|
+
* sync. Richer, and honest by construction.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} props
|
|
25
|
+
* @param {Array<{className:string, sample:React.ReactNode, label?:string}>} props.specs
|
|
26
|
+
* The rows. `className` is the type class to render + measure; `sample` is the
|
|
27
|
+
* live copy; `label` overrides the printed class name (defaults to className).
|
|
28
|
+
* @param {string} [props.title] Optional header title (rendered verbatim).
|
|
29
|
+
* @param {string} [props.description] Optional header description (verbatim).
|
|
30
|
+
* @param {string} [props.sampleClassName] Extra class(es) on every sample element
|
|
31
|
+
* (default `text-emphasis` for a legible default color; pass '' to opt out).
|
|
32
|
+
* @param {Object<number,string>} [props.sansWeightNames] weight→name for
|
|
33
|
+
* proportional faces (default PP Right Grotesk ramp).
|
|
34
|
+
* @param {Object<number,string>} [props.monoWeightNames] weight→name for mono
|
|
35
|
+
* faces (default JetBrains Mono ramp).
|
|
36
|
+
* @param {{mono:string, sans:string}} [props.typefaceNames] Friendly typeface
|
|
37
|
+
* labels chosen by the mono/proportional split (default RG / JetBrains).
|
|
38
|
+
* @param {string} [props.className] Class(es) on the root <div>.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const DEFAULT_SANS_WEIGHTS = { 100: 'Fine', 300: 'Light', 400: 'Regular', 500: 'Medium', 600: 'Dark', 700: 'Bold', 900: 'Black' }
|
|
42
|
+
const DEFAULT_MONO_WEIGHTS = { 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold' }
|
|
43
|
+
const DEFAULT_TYPEFACE_NAMES = { mono: 'JetBrains Mono', sans: 'Right Grotesk' }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read a computed-style object into a printable metric record. Exported so
|
|
47
|
+
* consumers can reuse the exact same derivation in tests or custom rows.
|
|
48
|
+
*/
|
|
49
|
+
export function readMeta(cs, {
|
|
50
|
+
sansWeightNames = DEFAULT_SANS_WEIGHTS,
|
|
51
|
+
monoWeightNames = DEFAULT_MONO_WEIGHTS,
|
|
52
|
+
typefaceNames = DEFAULT_TYPEFACE_NAMES,
|
|
53
|
+
} = {}) {
|
|
54
|
+
const fam = cs.fontFamily.split(',')[0].replace(/["']/g, '').trim()
|
|
55
|
+
const mono = /mono/i.test(fam)
|
|
56
|
+
const typeface = mono ? typefaceNames.mono : typefaceNames.sans
|
|
57
|
+
const width = /Narrow/.test(fam) ? 'Narrow' : /Compact/.test(fam) ? 'Compact' : mono ? 'Monospace' : 'Regular'
|
|
58
|
+
const w = parseInt(cs.fontWeight, 10)
|
|
59
|
+
const wname = (mono ? monoWeightNames : sansWeightNames)[w]
|
|
60
|
+
const size = parseFloat(cs.fontSize)
|
|
61
|
+
const lh = parseFloat(cs.lineHeight)
|
|
62
|
+
const leading = Number.isNaN(lh) ? cs.lineHeight : `${+lh.toFixed(1)}px (${(lh / size).toFixed(2)}×)`
|
|
63
|
+
const ls = parseFloat(cs.letterSpacing)
|
|
64
|
+
const tracking = !ls ? '0' : `${cs.letterSpacing} (${(ls / size).toFixed(3)}em)`
|
|
65
|
+
return { fam, typeface, width, weight: `${w}${wname ? ` ${wname}` : ''}`, size: `${+size.toFixed(1)}px`, leading, tracking }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function SpecimenRow({ className, sample, label, sampleClassName, metaConfig }) {
|
|
69
|
+
const ref = useRef(null)
|
|
70
|
+
const [m, setM] = useState(null)
|
|
71
|
+
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
const measure = () => { if (ref.current) setM(readMeta(getComputedStyle(ref.current), metaConfig)) }
|
|
74
|
+
measure()
|
|
75
|
+
// Re-measure once webfonts land — line-height can resolve as `normal`
|
|
76
|
+
// (font-dependent) before the face loads, which would print a stale leading.
|
|
77
|
+
let cancelled = false
|
|
78
|
+
if (typeof document !== 'undefined' && document.fonts?.ready) {
|
|
79
|
+
document.fonts.ready.then(() => { if (!cancelled) measure() })
|
|
80
|
+
}
|
|
81
|
+
return () => { cancelled = true }
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, [className])
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex flex-col gap-2 py-4 border-b border-fg-08">
|
|
87
|
+
<div ref={ref} className={sampleClassName ? `${className} ${sampleClassName}` : className}>{sample}</div>
|
|
88
|
+
{m && (
|
|
89
|
+
<div className="kol-mono-10 text-meta flex flex-col gap-0.5">
|
|
90
|
+
<span className="text-body">{label ?? className}</span>
|
|
91
|
+
<span>{m.typeface} · {m.fam} · {m.width} · {m.weight}</span>
|
|
92
|
+
<span>{m.size} · leading {m.leading} · tracking {m.tracking}</span>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default function TypeSpecimenLive({
|
|
100
|
+
specs = [],
|
|
101
|
+
title,
|
|
102
|
+
description,
|
|
103
|
+
sampleClassName = 'text-emphasis',
|
|
104
|
+
sansWeightNames = DEFAULT_SANS_WEIGHTS,
|
|
105
|
+
monoWeightNames = DEFAULT_MONO_WEIGHTS,
|
|
106
|
+
typefaceNames = DEFAULT_TYPEFACE_NAMES,
|
|
107
|
+
className = '',
|
|
108
|
+
}) {
|
|
109
|
+
const metaConfig = { sansWeightNames, monoWeightNames, typefaceNames }
|
|
110
|
+
return (
|
|
111
|
+
<div className={`kol-type-specimen-live flex flex-col ${className}`}>
|
|
112
|
+
{(title || description) && (
|
|
113
|
+
<header className="mb-4">
|
|
114
|
+
{title && <h2 className="kol-sans-heading-04 text-emphasis mb-3">{title}</h2>}
|
|
115
|
+
{description && <p className="kol-sans-body-03 text-meta">{description}</p>}
|
|
116
|
+
</header>
|
|
117
|
+
)}
|
|
118
|
+
{specs.map((s, i) => (
|
|
119
|
+
<SpecimenRow
|
|
120
|
+
key={s.className ? `${s.className}-${i}` : i}
|
|
121
|
+
className={s.className}
|
|
122
|
+
sample={s.sample}
|
|
123
|
+
label={s.label}
|
|
124
|
+
sampleClassName={sampleClassName}
|
|
125
|
+
metaConfig={metaConfig}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,18 @@ export { default as TypefaceLibraryGridWithVariables } from './TypefaceLibraryGr
|
|
|
22
22
|
export { default as TypefaceLibraryItem } from './TypefaceLibraryItem.jsx'
|
|
23
23
|
export { default as TypefaceVariablePreview } from './TypefaceVariablePreview.jsx'
|
|
24
24
|
|
|
25
|
+
// type-specimen kit — prop-driven specimen blocks (moved from @kolkrabbi/kol-component 2026-07-09)
|
|
26
|
+
export { default as TypeSample } from './TypeSample.jsx'
|
|
27
|
+
export { default as TypeSpecCard } from './TypeSpecCard.jsx'
|
|
28
|
+
export { default as TypeSpecimenLive, readMeta } from './TypeSpecimenLive.jsx'
|
|
29
|
+
|
|
30
|
+
// font-metric hook — opentype.js parsing (optional peer, degrades gracefully)
|
|
31
|
+
export { default as useFontMetrics, extractFaceMetrics, buildOutlinePaths, glyphMetricsOf } from './useFontMetrics.js'
|
|
32
|
+
|
|
33
|
+
// live-font effects — variable-font axes, animated
|
|
34
|
+
export { default as TextPressure } from './TextPressure.jsx'
|
|
35
|
+
export { default as ColorLoader } from './ColorLoader.jsx'
|
|
36
|
+
|
|
25
37
|
// reference composition (severed page — data via props, no router/SEO/data-fetch)
|
|
26
38
|
export { default as TypefaceSpecimenPage } from './TypefaceSpecimenPage.jsx'
|
|
27
39
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useFontMetrics — parse a real font with opentype.js and expose its metrics +
|
|
5
|
+
* text→outline geometry. The parsing engine ported from the kol type-editor's
|
|
6
|
+
* `fontLoader.js` (async fetch + parse + promise cache) and `textOutline.js`
|
|
7
|
+
* (tracking-aware advance measurement, greedy soft-wrap, CSS half-leading
|
|
8
|
+
* baseline math). Generalised off the editor's `layer` object into plain args
|
|
9
|
+
* and stripped of the editor's case-folding — casing is a content concern, so
|
|
10
|
+
* text is measured and outlined verbatim.
|
|
11
|
+
*
|
|
12
|
+
* WHY IT EXISTS: `GlyphMetricsGrid` already injects a FontFace and parses face-
|
|
13
|
+
* level metrics (ascender/descender/cap/x-height) inline. This hook is the
|
|
14
|
+
* reusable, parse-only layer beneath that — and it adds what the grid can't
|
|
15
|
+
* currently show: per-glyph advance/bounding-box readouts and true glyph
|
|
16
|
+
* OUTLINE path data (`<path d>`) for any string, at any size/tracking, wrapped
|
|
17
|
+
* to a box. A consumer can feed those straight into an SVG specimen.
|
|
18
|
+
*
|
|
19
|
+
* GRACEFUL DEGRADATION: opentype.js is an OPTIONAL peer, loaded via dynamic
|
|
20
|
+
* import. Absent it, the hook resolves to `status: 'unavailable'` with a null
|
|
21
|
+
* font — every derived helper returns an empty/zero result instead of throwing,
|
|
22
|
+
* exactly like the grid's overlay simply not drawing.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} [fontUrl] Same-origin URL of a `.ttf`/`.otf` to fetch + parse.
|
|
25
|
+
* @param {Object} [options]
|
|
26
|
+
* @param {ArrayBuffer} [options.buffer] Pre-fetched font bytes — skips the fetch
|
|
27
|
+
* (use when a caller already holds the buffer, e.g. an upload). Takes priority
|
|
28
|
+
* over `fontUrl`; the two share the same parse/metric path.
|
|
29
|
+
* @param {string} [options.cacheKey] Override the cache key when passing a raw
|
|
30
|
+
* buffer (defaults to a per-buffer identity key, so repeat buffers re-parse).
|
|
31
|
+
* @returns {{
|
|
32
|
+
* status: 'idle'|'loading'|'ready'|'unavailable'|'error',
|
|
33
|
+
* opentypeAvailable: boolean,
|
|
34
|
+
* font: object|null,
|
|
35
|
+
* metrics: {unitsPerEm:number,ascender:number,descender:number,capHeight:number,xHeight:number}|null,
|
|
36
|
+
* error: Error|null,
|
|
37
|
+
* getAdvanceWidth: (text:string, size:number, opts?:{tracking?:number}) => number,
|
|
38
|
+
* getGlyphMetrics: (char:string, size?:number) => object|null,
|
|
39
|
+
* getOutlinePaths: (text:string, opts?:object) => string[],
|
|
40
|
+
* }}
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/* ── opentype loader — cached dynamic import; null once we know it's absent ──
|
|
44
|
+
* Mirrors GlyphMetricsGrid's inline dynamic import so the two agree on how the
|
|
45
|
+
* optional peer is resolved (mod.parse ?? mod.default.parse ?? mod.default). */
|
|
46
|
+
let opentypePromise = null
|
|
47
|
+
function loadOpentype() {
|
|
48
|
+
if (opentypePromise) return opentypePromise
|
|
49
|
+
opentypePromise = import('opentype.js')
|
|
50
|
+
.then((mod) => ({ parse: mod.parse || mod.default?.parse || mod.default }))
|
|
51
|
+
.catch(() => null)
|
|
52
|
+
return opentypePromise
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Resolved { font, metrics } promises, keyed like fontLoader's cache so repeat
|
|
56
|
+
* calls for the same cut never re-fetch or re-parse. A rejected/absent parse is
|
|
57
|
+
* evicted so a later attempt can retry. */
|
|
58
|
+
const cache = new Map()
|
|
59
|
+
|
|
60
|
+
/* Face-level metric extraction with the SAME fallback chains GlyphMetricsGrid
|
|
61
|
+
* uses (os2 ?? hhea ?? literal) so an incomplete font degrades instead of
|
|
62
|
+
* throwing. */
|
|
63
|
+
function extractFaceMetrics(font) {
|
|
64
|
+
const os2 = font.tables?.os2
|
|
65
|
+
const hhea = font.tables?.hhea
|
|
66
|
+
return {
|
|
67
|
+
unitsPerEm: font.unitsPerEm || 1000,
|
|
68
|
+
ascender: os2?.sTypoAscender ?? hhea?.ascender ?? 800,
|
|
69
|
+
descender: os2?.sTypoDescender ?? hhea?.descender ?? -200,
|
|
70
|
+
capHeight: os2?.sCapHeight ?? 700,
|
|
71
|
+
xHeight: os2?.sxHeight ?? 500,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function loadFontMetrics(key, source) {
|
|
76
|
+
if (cache.has(key)) return cache.get(key)
|
|
77
|
+
const promise = (async () => {
|
|
78
|
+
const ot = await loadOpentype()
|
|
79
|
+
if (!ot?.parse) return { font: null, metrics: null, reason: 'unavailable' }
|
|
80
|
+
const buffer =
|
|
81
|
+
source.buffer ??
|
|
82
|
+
(await fetch(source.url).then((r) => {
|
|
83
|
+
if (!r.ok) throw new Error(`Font fetch failed: ${source.url}`)
|
|
84
|
+
return r.arrayBuffer()
|
|
85
|
+
}))
|
|
86
|
+
const font = ot.parse(buffer)
|
|
87
|
+
return { font, metrics: extractFaceMetrics(font), reason: 'ready' }
|
|
88
|
+
})()
|
|
89
|
+
cache.set(key, promise)
|
|
90
|
+
promise.catch(() => cache.delete(key))
|
|
91
|
+
return promise
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* opentype render options for an em tracking value. Non-zero tracking clears
|
|
95
|
+
* ligature features — matching CSS ("UAs should not apply optional ligatures
|
|
96
|
+
* when letter-spacing is non-zero"); kerning stays on via the defaults. */
|
|
97
|
+
const renderOpts = (track) =>
|
|
98
|
+
!track ? {} : { letterSpacing: track, features: [] }
|
|
99
|
+
|
|
100
|
+
/* Greedy soft-wrap of one hard line to maxW, measured with the same advance/
|
|
101
|
+
* kerning/letter-spacing engine that draws the glyphs. Whitespace never forces
|
|
102
|
+
* a break (trailing space hangs); a word wider than the box splits at char
|
|
103
|
+
* level (overflow-wrap: break-word). Ported verbatim from textOutline.js. */
|
|
104
|
+
function wrapLine(font, text, size, opts, maxW) {
|
|
105
|
+
if (!text.trim() || maxW <= 0) return [text]
|
|
106
|
+
const measure = (s) => font.getAdvanceWidth(s.replace(/\s+$/, ''), size, opts)
|
|
107
|
+
const tokens = text.split(/(\s+)/).filter(Boolean)
|
|
108
|
+
const lines = []
|
|
109
|
+
let cur = ''
|
|
110
|
+
for (const tok of tokens) {
|
|
111
|
+
if (/^\s+$/.test(tok)) { cur += tok; continue }
|
|
112
|
+
if (cur.trim() && measure(cur + tok) > maxW) { lines.push(cur); cur = tok }
|
|
113
|
+
else cur += tok
|
|
114
|
+
while (measure(cur) > maxW && cur.trim().length > 1) {
|
|
115
|
+
let n = cur.length - 1
|
|
116
|
+
while (n > 1 && measure(cur.slice(0, n)) > maxW) n -= 1
|
|
117
|
+
lines.push(cur.slice(0, n))
|
|
118
|
+
cur = cur.slice(n)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push(cur)
|
|
122
|
+
return lines
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Glyph outlines as layer-local `<path d>` strings, one per rendered line.
|
|
127
|
+
* Ported from textOutline.js `textOutlinePaths` — baselines via the CSS half-
|
|
128
|
+
* leading model over hhea ascent/descent (what Chrome/mac uses for the line-box
|
|
129
|
+
* content area). Text is outlined verbatim (no case-folding). When `boxWidth`
|
|
130
|
+
* is 0 the text is treated as a single unwrapped line; when `boxHeight` is 0 the
|
|
131
|
+
* block is top-anchored (top = 0) rather than vertically centered.
|
|
132
|
+
*/
|
|
133
|
+
function buildOutlinePaths(font, metrics, text, opts = {}) {
|
|
134
|
+
const {
|
|
135
|
+
size = 96,
|
|
136
|
+
tracking = 0,
|
|
137
|
+
lineHeight = 1.05,
|
|
138
|
+
align = 'left',
|
|
139
|
+
boxWidth = 0,
|
|
140
|
+
boxHeight = 0,
|
|
141
|
+
decimals = 2,
|
|
142
|
+
} = opts
|
|
143
|
+
const display = String(text ?? '')
|
|
144
|
+
if (!display.trim()) return []
|
|
145
|
+
|
|
146
|
+
const ropts = renderOpts(tracking)
|
|
147
|
+
const lines = display
|
|
148
|
+
.split('\n')
|
|
149
|
+
.flatMap((ln) => (boxWidth > 0 ? wrapLine(font, ln, size, ropts, boxWidth) : [ln]))
|
|
150
|
+
|
|
151
|
+
const scale = size / metrics.unitsPerEm
|
|
152
|
+
const ascent = metrics.ascender * scale
|
|
153
|
+
const descent = -metrics.descender * scale /* hhea descender is negative */
|
|
154
|
+
const lineH = lineHeight * size
|
|
155
|
+
const blockH = Math.max(lines.length * lineH, size) /* min-height: 1em */
|
|
156
|
+
const top = boxHeight > 0 ? (boxHeight - blockH) / 2 : 0
|
|
157
|
+
|
|
158
|
+
const paths = []
|
|
159
|
+
lines.forEach((line, i) => {
|
|
160
|
+
const t = line.replace(/\s+$/, '') /* trailing spaces hang */
|
|
161
|
+
if (!t) return /* blank line still holds its slot */
|
|
162
|
+
const lineW = font.getAdvanceWidth(t, size, ropts)
|
|
163
|
+
const lx =
|
|
164
|
+
align === 'left' ? 0 : align === 'right' ? boxWidth - lineW : (boxWidth - lineW) / 2
|
|
165
|
+
const baseY = top + i * lineH + (lineH - (ascent + descent)) / 2 + ascent
|
|
166
|
+
const d = font.getPath(t, lx, baseY, size, ropts).toPathData(decimals)
|
|
167
|
+
if (d) paths.push(d)
|
|
168
|
+
})
|
|
169
|
+
return paths
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Per-glyph readout for the first char of `char`, in font units and (if a size
|
|
173
|
+
* is given) scaled px. Upgrades GlyphMetricsGrid: it can now print each cell's
|
|
174
|
+
* real advance + bounding box, not just the face-level lines. */
|
|
175
|
+
function glyphMetricsOf(font, char, size) {
|
|
176
|
+
const ch = String(char ?? '').charAt(0)
|
|
177
|
+
if (!ch) return null
|
|
178
|
+
const glyph = font.charToGlyph(ch)
|
|
179
|
+
if (!glyph) return null
|
|
180
|
+
const bbox = glyph.getBoundingBox?.() ?? { x1: 0, y1: 0, x2: 0, y2: 0 }
|
|
181
|
+
const advanceUnits = glyph.advanceWidth ?? 0
|
|
182
|
+
const base = {
|
|
183
|
+
name: glyph.name,
|
|
184
|
+
unicode: glyph.unicode,
|
|
185
|
+
index: glyph.index,
|
|
186
|
+
advanceWidth: advanceUnits,
|
|
187
|
+
boundingBox: bbox,
|
|
188
|
+
leftSideBearing: glyph.leftSideBearing ?? bbox.x1,
|
|
189
|
+
}
|
|
190
|
+
if (!size) return base
|
|
191
|
+
const scale = size / (font.unitsPerEm || 1000)
|
|
192
|
+
return {
|
|
193
|
+
...base,
|
|
194
|
+
advanceWidthPx: advanceUnits * scale,
|
|
195
|
+
widthPx: (bbox.x2 - bbox.x1) * scale,
|
|
196
|
+
heightPx: (bbox.y2 - bbox.y1) * scale,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default function useFontMetrics(fontUrl, options = {}) {
|
|
201
|
+
const { buffer, cacheKey } = options
|
|
202
|
+
const key = buffer ? cacheKey ?? `buffer:${cacheKey ?? Math.random()}` : fontUrl
|
|
203
|
+
|
|
204
|
+
const [state, setState] = useState({
|
|
205
|
+
status: 'idle',
|
|
206
|
+
font: null,
|
|
207
|
+
metrics: null,
|
|
208
|
+
error: null,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!buffer && !fontUrl) {
|
|
213
|
+
setState({ status: 'idle', font: null, metrics: null, error: null })
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
let cancelled = false
|
|
217
|
+
setState((s) => ({ ...s, status: 'loading', error: null }))
|
|
218
|
+
loadFontMetrics(key, buffer ? { buffer } : { url: fontUrl })
|
|
219
|
+
.then(({ font, metrics, reason }) => {
|
|
220
|
+
if (cancelled) return
|
|
221
|
+
if (!font) { setState({ status: 'unavailable', font: null, metrics: null, error: null }); return }
|
|
222
|
+
setState({ status: reason === 'ready' ? 'ready' : 'unavailable', font, metrics, error: null })
|
|
223
|
+
})
|
|
224
|
+
.catch((error) => {
|
|
225
|
+
if (!cancelled) setState({ status: 'error', font: null, metrics: null, error })
|
|
226
|
+
})
|
|
227
|
+
return () => { cancelled = true }
|
|
228
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
229
|
+
}, [key])
|
|
230
|
+
|
|
231
|
+
const { font, metrics } = state
|
|
232
|
+
|
|
233
|
+
// Stable helper identities per parsed font — safe to pass to children/memos.
|
|
234
|
+
const helpers = useMemo(() => ({
|
|
235
|
+
getAdvanceWidth: (text, size, opts = {}) =>
|
|
236
|
+
font ? font.getAdvanceWidth(String(text ?? ''), size, renderOpts(opts.tracking)) : 0,
|
|
237
|
+
getGlyphMetrics: (char, size) => (font ? glyphMetricsOf(font, char, size) : null),
|
|
238
|
+
getOutlinePaths: (text, opts) => (font && metrics ? buildOutlinePaths(font, metrics, text, opts) : []),
|
|
239
|
+
}), [font, metrics])
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
status: state.status,
|
|
243
|
+
opentypeAvailable: state.status !== 'unavailable',
|
|
244
|
+
font,
|
|
245
|
+
metrics,
|
|
246
|
+
error: state.error,
|
|
247
|
+
...helpers,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export { extractFaceMetrics, buildOutlinePaths, glyphMetricsOf }
|