@kolkrabbi/kol-foundry 0.1.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/README.md +5 -1
- package/package.json +6 -5
- package/src/ColorLoader.jsx +155 -0
- package/src/GlyphMetricsSection.jsx +107 -0
- package/src/TextPressure.jsx +331 -0
- package/src/TypeSample.jsx +50 -0
- package/src/TypeSpecCard.jsx +42 -0
- package/src/TypefaceLibraryGrid.jsx +75 -0
- package/src/TypefaceLibraryGridWithVariables.jsx +236 -0
- package/src/TypefaceLibraryItem.jsx +190 -0
- package/src/TypefaceSpecimenPage.jsx +223 -0
- package/src/TypefaceVariablePreview.jsx +160 -0
- package/src/index.js +26 -9
- package/src/typefaceConfig.js +227 -0
|
@@ -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,75 @@
|
|
|
1
|
+
import { ContentFilters } from '@kolkrabbi/kol-component'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TypefaceLibraryGrid — filtered typeface library grid. Thin wrapper around the
|
|
5
|
+
* DS `ContentFilters` configured for typefaces (Classification + Styles filter
|
|
6
|
+
* groups, card/list view toggle). The parent supplies `renderItem` so it can
|
|
7
|
+
* wrap each entry in its own navigation.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} props
|
|
10
|
+
* @param {Array} props.typefaces - Typeface objects.
|
|
11
|
+
* @param {Function} props.renderItem - (typeface, viewMode) => node, wraps each item.
|
|
12
|
+
* @param {number} props.totalCount - Total count of all typefaces.
|
|
13
|
+
*/
|
|
14
|
+
const TypefaceLibraryGrid = ({
|
|
15
|
+
typefaces,
|
|
16
|
+
renderItem,
|
|
17
|
+
totalCount
|
|
18
|
+
}) => {
|
|
19
|
+
// Extract unique values for filter groups
|
|
20
|
+
const classifications = [...new Set(typefaces.map((t) => t.classification))].sort()
|
|
21
|
+
const styles = [...new Set(typefaces.map((t) => t.styles))].sort()
|
|
22
|
+
|
|
23
|
+
const filterGroups = [
|
|
24
|
+
{
|
|
25
|
+
label: 'Classification',
|
|
26
|
+
key: 'classification',
|
|
27
|
+
values: classifications
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
label: 'Styles',
|
|
31
|
+
key: 'styles',
|
|
32
|
+
values: styles
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const viewModeOptions = [
|
|
37
|
+
{ value: 'card', label: 'Card view' },
|
|
38
|
+
{ value: 'list', label: 'List view' }
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
// Render items in grid or list based on view mode
|
|
42
|
+
const renderItemsWithLayout = (items, viewMode) => {
|
|
43
|
+
if (viewMode === 'card') {
|
|
44
|
+
return (
|
|
45
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
46
|
+
{items.map((typeface) => renderItem(typeface, 'card'))}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="space-y-3">
|
|
53
|
+
{items.map((typeface) => renderItem(typeface, 'list'))}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<section className="w-full py-16">
|
|
60
|
+
<div className="max-w-[1400px] mx-auto">
|
|
61
|
+
<ContentFilters
|
|
62
|
+
items={typefaces}
|
|
63
|
+
title="All Typefaces"
|
|
64
|
+
totalCount={totalCount}
|
|
65
|
+
filterGroups={filterGroups}
|
|
66
|
+
renderItem={(items, viewMode) => renderItemsWithLayout(items, viewMode)}
|
|
67
|
+
viewModeOptions={viewModeOptions}
|
|
68
|
+
defaultViewMode="list"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default TypefaceLibraryGrid
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { useState, useMemo, useEffect, useRef } from 'react'
|
|
2
|
+
import { ContentFilters } from '@kolkrabbi/kol-component'
|
|
3
|
+
import TypefaceLibraryItem from './TypefaceLibraryItem.jsx'
|
|
4
|
+
import TypefaceVariablePreview from './TypefaceVariablePreview.jsx'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TypefaceLibraryGridWithVariables — the library grid with a "By Typeface"
|
|
8
|
+
* filter mode. Default mode shows standard TypefaceLibraryItem cards; selecting
|
|
9
|
+
* a single typeface swaps to TypefaceVariablePreview per weight variant, with an
|
|
10
|
+
* optional Axes filter for multi-axis families.
|
|
11
|
+
*
|
|
12
|
+
* Router-severed: pass an injected `linkComponent` (e.g. your router's `Link`)
|
|
13
|
+
* to wrap each item — it receives a `to` prop. When omitted, items render as a
|
|
14
|
+
* plain `<a href>`. Report: replaced the monorepo's `react-router-dom` `Link`.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} props
|
|
17
|
+
* @param {Array} props.typefaces - Typeface objects.
|
|
18
|
+
* @param {Object} props.typefaceWeights - Map of typeface name → weight variant array.
|
|
19
|
+
* @param {number} props.totalCount - Total count of all typefaces.
|
|
20
|
+
* @param {React.ElementType} props.linkComponent - Optional link wrapper (receives `to`); defaults to `<a href>`.
|
|
21
|
+
*/
|
|
22
|
+
const TypefaceLibraryGridWithVariables = ({
|
|
23
|
+
typefaces,
|
|
24
|
+
typefaceWeights = {},
|
|
25
|
+
totalCount,
|
|
26
|
+
linkComponent
|
|
27
|
+
}) => {
|
|
28
|
+
const [activeFilters, setActiveFilters] = useState(new Set())
|
|
29
|
+
const [viewMode, setViewMode] = useState('list')
|
|
30
|
+
const [activeIndex, setActiveIndex] = useState(null)
|
|
31
|
+
const prevModeRef = useRef(null)
|
|
32
|
+
|
|
33
|
+
const LinkEl = linkComponent || 'a'
|
|
34
|
+
const linkPropsFor = (dest) => (linkComponent ? { to: dest } : { href: dest })
|
|
35
|
+
|
|
36
|
+
// Reset active index when filters change
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setActiveIndex(null)
|
|
39
|
+
}, [activeFilters])
|
|
40
|
+
|
|
41
|
+
// Handle filter changes with mutual exclusivity for typeface selection
|
|
42
|
+
const handleFilterChange = (newFilters) => {
|
|
43
|
+
// Check if a typeface (name) filter was added
|
|
44
|
+
const newTypefaceFilter = Array.from(newFilters).find(f => f.startsWith('name:'))
|
|
45
|
+
const oldTypefaceFilter = Array.from(activeFilters).find(f => f.startsWith('name:'))
|
|
46
|
+
|
|
47
|
+
// If a new typeface is selected and it's different from the old one,
|
|
48
|
+
// remove the old typeface filter to enforce mutual exclusivity
|
|
49
|
+
if (newTypefaceFilter && oldTypefaceFilter && newTypefaceFilter !== oldTypefaceFilter) {
|
|
50
|
+
const updatedFilters = new Set(newFilters)
|
|
51
|
+
updatedFilters.delete(oldTypefaceFilter)
|
|
52
|
+
setActiveFilters(updatedFilters)
|
|
53
|
+
} else {
|
|
54
|
+
setActiveFilters(newFilters)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Extract unique values for filter groups
|
|
59
|
+
const classifications = [...new Set(typefaces.map((t) => t.classification))].sort()
|
|
60
|
+
const styles = [...new Set(typefaces.map((t) => t.styles))].sort()
|
|
61
|
+
const typefaceNames = typefaces.map((t) => t.name).sort()
|
|
62
|
+
|
|
63
|
+
// Check if a specific typeface is selected
|
|
64
|
+
const selectedTypeface = useMemo(() => {
|
|
65
|
+
const typefaceFilter = Array.from(activeFilters).find(f => f.startsWith('name:'))
|
|
66
|
+
return typefaceFilter ? typefaceFilter.split(':')[1] : null
|
|
67
|
+
}, [activeFilters])
|
|
68
|
+
|
|
69
|
+
// Get available axes for selected typeface
|
|
70
|
+
const availableAxes = useMemo(() => {
|
|
71
|
+
if (!selectedTypeface || !typefaceWeights[selectedTypeface]) return []
|
|
72
|
+
const weights = typefaceWeights[selectedTypeface]
|
|
73
|
+
const axes = [...new Set(weights.map(w => w.axis).filter(Boolean))]
|
|
74
|
+
return axes
|
|
75
|
+
}, [selectedTypeface, typefaceWeights])
|
|
76
|
+
|
|
77
|
+
// Filter groups - dynamically add Axes filter if multi-axis typeface selected
|
|
78
|
+
const filterGroups = useMemo(() => {
|
|
79
|
+
const baseGroups = [
|
|
80
|
+
{ label: 'Classification', key: 'classification', values: classifications },
|
|
81
|
+
{ label: 'Styles', key: 'styles', values: styles },
|
|
82
|
+
{ label: 'Typefaces', key: 'name', values: typefaceNames }
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
// Add Axes filter if selected typeface has multiple axes
|
|
86
|
+
if (selectedTypeface && availableAxes.length > 1) {
|
|
87
|
+
baseGroups.push({
|
|
88
|
+
label: 'Axes',
|
|
89
|
+
key: 'axis',
|
|
90
|
+
values: availableAxes
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return baseGroups
|
|
95
|
+
}, [classifications, styles, typefaceNames, selectedTypeface, availableAxes])
|
|
96
|
+
|
|
97
|
+
// Filtered items
|
|
98
|
+
const filteredItems = useMemo(() => {
|
|
99
|
+
if (activeFilters.size === 0) return typefaces
|
|
100
|
+
|
|
101
|
+
return typefaces.filter((typeface) => {
|
|
102
|
+
let matches = true
|
|
103
|
+
activeFilters.forEach((filter) => {
|
|
104
|
+
const [filterType, value] = filter.split(':')
|
|
105
|
+
if (filterType === 'typeface' && typeface.name !== value) {
|
|
106
|
+
matches = false
|
|
107
|
+
}
|
|
108
|
+
if (filterType === 'classification' && typeface.classification !== value) {
|
|
109
|
+
matches = false
|
|
110
|
+
}
|
|
111
|
+
if (filterType === 'styles' && typeface.styles !== value) {
|
|
112
|
+
matches = false
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
return matches
|
|
116
|
+
})
|
|
117
|
+
}, [typefaces, activeFilters])
|
|
118
|
+
|
|
119
|
+
// View mode options
|
|
120
|
+
const viewModeOptions = [
|
|
121
|
+
{ value: 'card', label: 'Card view' },
|
|
122
|
+
{ value: 'list', label: 'List view' }
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
// Render items based on filter mode
|
|
126
|
+
const renderItems = (items, mode) => {
|
|
127
|
+
// Reset active index when view mode changes
|
|
128
|
+
if (prevModeRef.current !== null && prevModeRef.current !== mode) {
|
|
129
|
+
setActiveIndex(null)
|
|
130
|
+
}
|
|
131
|
+
prevModeRef.current = mode
|
|
132
|
+
|
|
133
|
+
// If a specific typeface is selected, show weight variants
|
|
134
|
+
if (selectedTypeface && typefaceWeights[selectedTypeface]) {
|
|
135
|
+
const typeface = items.find(t => t.name === selectedTypeface)
|
|
136
|
+
if (!typeface) return null
|
|
137
|
+
|
|
138
|
+
let weights = typefaceWeights[selectedTypeface]
|
|
139
|
+
|
|
140
|
+
// Filter by selected axes if any axis filters are active
|
|
141
|
+
const selectedAxes = Array.from(activeFilters)
|
|
142
|
+
.filter(f => f.startsWith('axis:'))
|
|
143
|
+
.map(f => f.split(':')[1])
|
|
144
|
+
|
|
145
|
+
if (selectedAxes.length > 0) {
|
|
146
|
+
weights = weights.filter(w => selectedAxes.includes(w.axis))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (mode === 'card') {
|
|
150
|
+
return (
|
|
151
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
152
|
+
{weights.map((w) => (
|
|
153
|
+
<TypefaceVariablePreview
|
|
154
|
+
key={`${typeface.name}-${w.weight}`}
|
|
155
|
+
typeface={typeface}
|
|
156
|
+
weight={w.weight}
|
|
157
|
+
weightValue={w.value}
|
|
158
|
+
variant="card"
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// List view
|
|
166
|
+
return (
|
|
167
|
+
<div className="space-y-6">
|
|
168
|
+
{weights.map((w) => (
|
|
169
|
+
<TypefaceVariablePreview
|
|
170
|
+
key={`${typeface.name}-${w.weight}`}
|
|
171
|
+
typeface={typeface}
|
|
172
|
+
weight={w.weight}
|
|
173
|
+
weightValue={w.value}
|
|
174
|
+
variant="list"
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Default mode: show standard typeface cards
|
|
182
|
+
if (mode === 'card') {
|
|
183
|
+
return (
|
|
184
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
185
|
+
{items.map((typeface, index) => (
|
|
186
|
+
<LinkEl key={typeface.link} {...linkPropsFor(typeface.link)}>
|
|
187
|
+
<TypefaceLibraryItem
|
|
188
|
+
typeface={typeface}
|
|
189
|
+
variant="card"
|
|
190
|
+
isActive={activeIndex === index}
|
|
191
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
192
|
+
/>
|
|
193
|
+
</LinkEl>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// List view
|
|
200
|
+
return (
|
|
201
|
+
<div className="space-y-6">
|
|
202
|
+
{items.map((typeface, index) => (
|
|
203
|
+
<LinkEl key={typeface.link} {...linkPropsFor(typeface.link)}>
|
|
204
|
+
<TypefaceLibraryItem
|
|
205
|
+
typeface={typeface}
|
|
206
|
+
variant="list"
|
|
207
|
+
isActive={activeIndex === index}
|
|
208
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
209
|
+
/>
|
|
210
|
+
</LinkEl>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<section className="w-full py-16">
|
|
218
|
+
<div className="max-w-[1400px] mx-auto">
|
|
219
|
+
<ContentFilters
|
|
220
|
+
items={typefaces}
|
|
221
|
+
title="All Typefaces (With Variable Preview)"
|
|
222
|
+
totalCount={totalCount}
|
|
223
|
+
filterGroups={filterGroups}
|
|
224
|
+
renderItem={(items, mode) => renderItems(items, mode)}
|
|
225
|
+
viewModeOptions={viewModeOptions}
|
|
226
|
+
defaultViewMode="list"
|
|
227
|
+
onFilterChange={handleFilterChange}
|
|
228
|
+
mutuallyExclusiveFilters={['name']}
|
|
229
|
+
customFilterKeys={['axis']}
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
</section>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export default TypefaceLibraryGridWithVariables
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useState, useRef, useLayoutEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TypefaceLibraryItem — a single typeface entry in the library, in either card
|
|
5
|
+
* or list layout. Card: vertical layout with a large "Ðð" preview that swaps to
|
|
6
|
+
* a pangram on hover. List: horizontal layout with a width-clipped alphabet
|
|
7
|
+
* preview (binary-search clipping via ResizeObserver, mirroring DisplaySpecimen).
|
|
8
|
+
*
|
|
9
|
+
* Meant to be wrapped by the parent for navigation (see TypefaceLibraryGrid*).
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} props
|
|
12
|
+
* @param {Object} props.typeface - Typeface data ({ name, styles, classification, year, ... }).
|
|
13
|
+
* @param {'card'|'list'} props.variant - Display variant (default 'card').
|
|
14
|
+
* @param {boolean} props.isActive - Whether this item is the last-hovered/active one.
|
|
15
|
+
* @param {Function} props.onMouseEnter - Callback fired on mouse enter.
|
|
16
|
+
*/
|
|
17
|
+
const TypefaceLibraryItem = ({ typeface, variant = 'card', isActive = false, onMouseEnter }) => {
|
|
18
|
+
const containerRef = useRef(null)
|
|
19
|
+
const textRef = useRef(null)
|
|
20
|
+
const [visibleText, setVisibleText] = useState('Aa Bb Cc Dd Ee Ff Gg Hh Ii Jj Kk Ll Mm Nn Oo Pp Qq Rr Ss Tt Uu Vv Ww Xx Yy Zz')
|
|
21
|
+
// Map typeface name to font family
|
|
22
|
+
const fontFamily = typeface.name === 'TG Rót' ? 'TGRoot' :
|
|
23
|
+
typeface.name === 'TG Tröllatunga' ? 'TGTrollatunga' :
|
|
24
|
+
typeface.name === 'TG Dylgjur' ? 'TGDylgjur' :
|
|
25
|
+
typeface.name === 'TG Gullhamrar' ? 'TGGullhamrar' :
|
|
26
|
+
'TGMalromur'
|
|
27
|
+
|
|
28
|
+
const fontStyle = typeface.name === 'TG Málrómur' ? 'italic' : 'normal'
|
|
29
|
+
|
|
30
|
+
const fullAlphabet = 'Aa Bb Cc Dd Ee Ff Gg Hh Ii Jj Kk Ll Mm Nn Oo Pp Qq Rr Ss Tt Uu Vv Ww Xx Yy Zz'
|
|
31
|
+
|
|
32
|
+
// TEXT CLIPPING LOGIC - similar to DisplaySpecimen but for horizontal width
|
|
33
|
+
useLayoutEffect(() => {
|
|
34
|
+
if (variant !== 'list') return
|
|
35
|
+
|
|
36
|
+
const container = containerRef.current
|
|
37
|
+
const textElement = textRef.current
|
|
38
|
+
if (!container || !textElement) return
|
|
39
|
+
|
|
40
|
+
const calculateClipping = () => {
|
|
41
|
+
if (container.clientWidth === 0) return
|
|
42
|
+
const availableWidth = container.clientWidth - 40
|
|
43
|
+
const chars = fullAlphabet.split('')
|
|
44
|
+
let clippedText = ''
|
|
45
|
+
let low = 0
|
|
46
|
+
let high = chars.length
|
|
47
|
+
|
|
48
|
+
while (low <= high) {
|
|
49
|
+
const mid = Math.floor((low + high) / 2)
|
|
50
|
+
const testText = chars.slice(0, mid).join('')
|
|
51
|
+
textElement.textContent = testText
|
|
52
|
+
if (textElement.scrollWidth <= availableWidth) {
|
|
53
|
+
clippedText = testText
|
|
54
|
+
low = mid + 1
|
|
55
|
+
} else {
|
|
56
|
+
high = mid - 1
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setVisibleText(clippedText)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Use ResizeObserver so it fires when container actually has dimensions
|
|
64
|
+
const ro = new ResizeObserver(calculateClipping)
|
|
65
|
+
ro.observe(container)
|
|
66
|
+
|
|
67
|
+
window.addEventListener('resize', calculateClipping)
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
ro.disconnect()
|
|
71
|
+
window.removeEventListener('resize', calculateClipping)
|
|
72
|
+
}
|
|
73
|
+
}, [variant, fontFamily, fontStyle])
|
|
74
|
+
|
|
75
|
+
// Card variant
|
|
76
|
+
if (variant === 'card') {
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className={`group bg-surface-primary hover:bg-surface-inverse rounded transition-all duration-300 h-[500px] relative border border-fg-08 cursor-pointer ${
|
|
80
|
+
isActive ? 'bg-surface-inverse' : ''
|
|
81
|
+
}`}
|
|
82
|
+
onMouseEnter={onMouseEnter}
|
|
83
|
+
>
|
|
84
|
+
{/* Details at Top */}
|
|
85
|
+
<div className={`p-6 space-y-2 group-hover:opacity-0 transition-opacity duration-300 relative z-10 ${isActive ? 'opacity-0' : ''}`}>
|
|
86
|
+
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
87
|
+
<h3 className={`kol-helper-lg group-hover:text-auto-inverse transition-colors ${isActive ? 'text-auto-inverse' : 'text-auto'}`}>
|
|
88
|
+
{typeface.name}
|
|
89
|
+
</h3>
|
|
90
|
+
</div>
|
|
91
|
+
<p className={`kol-helper-s group-hover:text-fg-inverse-64 transition-colors ${isActive ? 'text-fg-inverse-64' : 'text-fg-64'}`}>
|
|
92
|
+
{typeface.styles}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* Preview Area */}
|
|
97
|
+
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-end justify-start p-8">
|
|
98
|
+
{/* Default: Ðð */}
|
|
99
|
+
<div
|
|
100
|
+
className={`text-[140px] lg:text-[160px] leading-none group-hover:text-auto-inverse group-hover:opacity-0 transition-all duration-300 relative z-10 ${
|
|
101
|
+
isActive ? 'text-auto-inverse opacity-0' : 'text-auto'
|
|
102
|
+
}`}
|
|
103
|
+
style={{
|
|
104
|
+
fontFamily,
|
|
105
|
+
fontStyle,
|
|
106
|
+
fontWeight: 400
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
Ðð
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Hover: Sentence - Covers entire card */}
|
|
114
|
+
<div className={`absolute inset-0 flex items-center justify-center p-8 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none ${
|
|
115
|
+
isActive ? 'opacity-100' : 'opacity-0'
|
|
116
|
+
}`}>
|
|
117
|
+
<p
|
|
118
|
+
className="text-auto-inverse text-4xl lg:text-5xl leading-tight text-center"
|
|
119
|
+
style={{
|
|
120
|
+
fontFamily,
|
|
121
|
+
fontStyle,
|
|
122
|
+
fontWeight: 400
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
The quick brown fox jumps over the lazy dog
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// List variant - compact horizontal style matching TypefaceVariablePreview
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
ref={containerRef}
|
|
136
|
+
className={`self-stretch min-h-40 p-6 rounded flex flex-col justify-start items-start gap-6 mb-6 overflow-hidden cursor-pointer transition-all duration-300 ${
|
|
137
|
+
isActive
|
|
138
|
+
? 'bg-[color-mix(in_srgb,var(--kol-surface-on-primary)_1%,transparent)] border border-[color-mix(in_srgb,var(--kol-surface-on-primary)_24%,transparent)]'
|
|
139
|
+
: 'bg-transparent border border-fg-08 hover:bg-[color-mix(in_srgb,var(--kol-surface-on-primary)_1%,transparent)] hover:border-[color-mix(in_srgb,var(--kol-surface-on-primary)_24%,transparent)]'
|
|
140
|
+
}`}
|
|
141
|
+
onMouseEnter={onMouseEnter}
|
|
142
|
+
>
|
|
143
|
+
{/* Header Row */}
|
|
144
|
+
<div className="self-stretch flex justify-between items-center">
|
|
145
|
+
{/* Left: Typeface Name & Info */}
|
|
146
|
+
<div className="w-64 flex justify-start items-start gap-6">
|
|
147
|
+
<div className="flex-1 flex flex-col justify-start items-start gap-3">
|
|
148
|
+
<div className="self-stretch flex flex-col justify-start items-start gap-2">
|
|
149
|
+
<div className="self-stretch kol-mono-sm uppercase">
|
|
150
|
+
{typeface.name}
|
|
151
|
+
</div>
|
|
152
|
+
<div className="self-stretch kol-mono-xs text-fg-64">
|
|
153
|
+
{typeface.styles}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Right: Status/Year info */}
|
|
160
|
+
<div className="flex flex-col items-end gap-2">
|
|
161
|
+
<span className="kol-mono-sm">
|
|
162
|
+
{typeface.classification}
|
|
163
|
+
</span>
|
|
164
|
+
<span className="kol-mono-xs text-fg-64">
|
|
165
|
+
{typeface.year}
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* Preview Text Row */}
|
|
171
|
+
<div className="self-stretch rounded flex justify-start items-center">
|
|
172
|
+
<div
|
|
173
|
+
ref={textRef}
|
|
174
|
+
className="flex-1 self-stretch justify-start text-auto font-black leading-[52px] whitespace-nowrap"
|
|
175
|
+
style={{
|
|
176
|
+
fontFamily,
|
|
177
|
+
fontStyle,
|
|
178
|
+
fontWeight: 400,
|
|
179
|
+
fontSize: '48px',
|
|
180
|
+
letterSpacing: '0'
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{visibleText}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default TypefaceLibraryItem
|