@kolkrabbi/kol-foundry 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-foundry",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
- "description": "KOL type-foundry system — the type-specimen apparatus: typeface hero, variable-font axis playground, parsed-metric glyph inspector, character-set browser, and font-preview. Typeface data is consumer-injected. Sits above @kolkrabbi/kol-{theme,icons,component}.",
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",
7
7
  "type": "module",
8
8
  "main": "./src/index.js",
@@ -11,9 +11,9 @@
11
11
  ".": "./src/index.js"
12
12
  },
13
13
  "dependencies": {
14
+ "@kolkrabbi/kol-theme": "0.7.1",
14
15
  "@kolkrabbi/kol-component": "0.7.0",
15
- "@kolkrabbi/kol-icons": "0.5.0",
16
- "@kolkrabbi/kol-theme": "0.7.1"
16
+ "@kolkrabbi/kol-icons": "0.5.0"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^18.3.0 || ^19.0.0",
@@ -0,0 +1,107 @@
1
+ import { useState } from 'react'
2
+ import SpecimenSectionHeader from './SpecimenSectionHeader.jsx'
3
+ import GlyphMetricsGrid from './GlyphMetricsGrid.jsx'
4
+
5
+ /**
6
+ * GlyphMetricsSection — the "Glyph Metrics" specimen section: a section header
7
+ * (Roman/Italic or Weight/Width axis dropdowns) over the parsed-metric
8
+ * GlyphMetricsGrid. Owns the style/axis selection state and feeds the grid the
9
+ * chosen font URL + `variationSettings`.
10
+ *
11
+ * Composes the package's existing canonical leaves — SpecimenSectionHeader
12
+ * (promoted from the monorepo's `FoundrySection`) and GlyphMetricsGrid (which
13
+ * already handles font I/O with opentype.js as an optional peer).
14
+ *
15
+ * @param {Object} props
16
+ * @param {string} props.fontFamily - CSS family fallback until the parsed face injects.
17
+ * @param {string} props.fontUrlRoman - Roman/upright font URL.
18
+ * @param {string} props.fontUrlItalic - Italic font URL.
19
+ * @param {'normal'|'italic'} props.fontStyle - Initial style variant.
20
+ * @param {string} props.badgeText - Section title text.
21
+ * @param {boolean} props.showDropdown - Show the Roman/Italic style dropdown.
22
+ * @param {boolean} props.hasWeight - Font has a weight axis.
23
+ * @param {boolean} props.hasWidth - Font has a width axis.
24
+ * @param {Array<{label,weight}>} props.weights - Weight axis options.
25
+ * @param {Array<{label,width}>} props.widths - Width axis options.
26
+ */
27
+ const GlyphMetricsSection = ({
28
+ fontFamily = 'TGMalromur',
29
+ fontUrlRoman,
30
+ fontUrlItalic,
31
+ fontStyle = 'normal',
32
+ badgeText = 'Málrómur',
33
+ showDropdown = true,
34
+ // Variable font axis support
35
+ hasWeight = false,
36
+ hasWidth = false,
37
+ weights = [],
38
+ widths = []
39
+ }) => {
40
+ const [selectedStyleVariant, setSelectedStyleVariant] = useState(
41
+ fontStyle === 'italic' ? 'italic' : 'roman'
42
+ )
43
+ const [selectedAxis] = useState('weight')
44
+ const [selectedWeight, setSelectedWeight] = useState(400)
45
+ const [selectedWidth, setSelectedWidth] = useState(100)
46
+
47
+ const isItalic = selectedStyleVariant === 'italic'
48
+
49
+ // Select the correct font URL based on the dropdown
50
+ const currentFontUrl = isItalic ? fontUrlItalic : fontUrlRoman
51
+
52
+ // Build axis options for dropdown
53
+ const axisOptions = []
54
+ if (hasWeight) axisOptions.push({ label: 'Weight', value: 'weight' })
55
+ if (hasWidth) axisOptions.push({ label: 'Width', value: 'width' })
56
+
57
+ // Build value options based on selected axis
58
+ const valueOptions = selectedAxis === 'weight'
59
+ ? weights.map(w => ({ label: w.label, value: w.weight }))
60
+ : widths.map(w => ({ label: w.label, value: w.width }))
61
+
62
+ const selectedValue = selectedAxis === 'weight' ? selectedWeight : selectedWidth
63
+ const onValueChange = selectedAxis === 'weight' ? setSelectedWeight : setSelectedWidth
64
+
65
+ // Show axis dropdown if font has variable axes
66
+ const showAxisDropdown = axisOptions.length > 0
67
+
68
+ // Get variation settings for the glyph display
69
+ const variationSettings = {}
70
+ if (hasWeight) variationSettings.wght = selectedWeight
71
+ if (hasWidth) variationSettings.wdth = selectedWidth
72
+
73
+ // Style dropdown: roman/italic when font has italic
74
+ const italicOptions = [
75
+ { label: 'Roman', value: 'roman' },
76
+ { label: 'Italic', value: 'italic' }
77
+ ]
78
+
79
+ return (
80
+ <section className="w-full py-12 lg:py-16">
81
+ <div className="max-w-[1400px] mx-auto flex flex-col gap-8">
82
+ <SpecimenSectionHeader
83
+ selectedStyle={showDropdown ? selectedStyleVariant : showAxisDropdown ? selectedValue : undefined}
84
+ onStyleChange={showDropdown ? setSelectedStyleVariant : showAxisDropdown ? onValueChange : undefined}
85
+ showDropdown={showDropdown || (showAxisDropdown && valueOptions.length > 0)}
86
+ styleOptions={showDropdown ? italicOptions : valueOptions}
87
+ badgeText={badgeText}
88
+ icon="foundation"
89
+ size="sm"
90
+ showWeightDropdown={showAxisDropdown && valueOptions.length > 0}
91
+ weightOptions={valueOptions}
92
+ selectedWeight={selectedValue}
93
+ onWeightChange={onValueChange}
94
+ />
95
+
96
+ <GlyphMetricsGrid
97
+ fontUrl={currentFontUrl}
98
+ fontFamily={fontFamily}
99
+ fontStyle="normal"
100
+ variationSettings={variationSettings}
101
+ />
102
+ </div>
103
+ </section>
104
+ )
105
+ }
106
+
107
+ export default GlyphMetricsSection
@@ -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
@@ -0,0 +1,223 @@
1
+ import { Button } from '@kolkrabbi/kol-component'
2
+
3
+ import TypefaceStyleSection from './TypefaceStyleSection.jsx'
4
+ import FontPreviewSection from './FontPreviewSection.jsx'
5
+ import VariableFontSection from './VariableFontSection.jsx'
6
+ import GlyphMetricsSection from './GlyphMetricsSection.jsx'
7
+
8
+ /**
9
+ * TypefaceSpecimenPage — the full data-driven typeface specimen composition:
10
+ * hero → styles → preview → variable axes → glyph metrics, interleaved with
11
+ * editorial photos. Drives every section generically from one `typeface` config
12
+ * object (see the bundled `typefaceConfig` / `getTypefaceConfig`).
13
+ *
14
+ * SEVERED from the monorepo route (`TypefacePage`), which is why this is a pure
15
+ * composition, not a route:
16
+ * - No router: navigation is an injected `linkComponent` prop (receives `to`),
17
+ * defaulting to a plain `<a href>`.
18
+ * - No app theme control: the route's `applyTheme`/`getInitialTheme` effect and
19
+ * `toggleTheme` were dropped — theme is the consuming app's concern.
20
+ * - No app-shell hero: the route's `FullBleedHero` (consumer chrome) is now an
21
+ * injectable `HeroComponent` prop. A minimal built-in `DefaultHero` renders the
22
+ * background photo when none is injected; pass the real hero to restore it.
23
+ * - No SEO / data-fetch wrapper: data arrives via the `typeface` prop.
24
+ *
25
+ * Text casing: displayName, category, description and labels render verbatim.
26
+ *
27
+ * @param {Object} props
28
+ * @param {Object} props.typeface - Typeface config object (see typefaceConfig).
29
+ * @param {string} props.titleClassName - Optional hero title className (kept for API parity).
30
+ * @param {React.ElementType} props.linkComponent - Optional link wrapper (receives `to`); defaults to `<a href>`.
31
+ * @param {React.ElementType} props.HeroComponent - Optional hero slot; defaults to a minimal built-in hero.
32
+ */
33
+
34
+ /** Minimal built-in hero used when no HeroComponent is injected — background
35
+ * photo with the specimen intro overlaid. Ignores FullBleedHero-only props. */
36
+ const DefaultHero = ({ image, srcSet, alt, children }) => (
37
+ <div className="relative w-full min-h-[70vh] flex items-center justify-center overflow-hidden rounded-[2px] bg-surface-secondary">
38
+ {image && (
39
+ <img
40
+ src={image}
41
+ srcSet={srcSet || undefined}
42
+ alt={alt}
43
+ className="absolute inset-0 w-full h-full object-cover"
44
+ loading="eager"
45
+ />
46
+ )}
47
+ <div className="relative z-10">{children}</div>
48
+ </div>
49
+ )
50
+
51
+ const TypefaceSpecimenPage = ({
52
+ typeface,
53
+ titleClassName = 'text-8xl',
54
+ linkComponent,
55
+ HeroComponent,
56
+ }) => {
57
+ const LinkEl = linkComponent || 'a'
58
+ const linkPropsFor = (dest) => (linkComponent ? { to: dest } : { href: dest })
59
+ const Hero = HeroComponent || DefaultHero
60
+
61
+ const {
62
+ displayName,
63
+ fontFamily,
64
+ fontUrl,
65
+ fontUrlRoman,
66
+ fontUrlItalic,
67
+ fontStyle,
68
+ badgeText,
69
+ category,
70
+ description,
71
+ styles,
72
+ photos = []
73
+ } = typeface
74
+
75
+ // Determine if variable font sections should be shown
76
+ const showVariableSection = styles.hasWeight || styles.hasWidth
77
+
78
+ // Get photo at index (all typefaces have CDN photos configured)
79
+ const getPhoto = (index) => photos[index]
80
+
81
+ // Generate srcSet from a photo URL by replacing the size
82
+ // Pattern: {path}-{size}.jpg → {path}-400.jpg 400w, {path}-800.jpg 800w, etc.
83
+ // Capped at 1600 as not all images have 2560 available
84
+ const getSrcSet = (photoUrl) => {
85
+ if (!photoUrl || !photoUrl.includes('-1200.jpg')) return null
86
+ const basePath = photoUrl.replace('-1200.jpg', '')
87
+ return `${basePath}-400.jpg 400w, ${basePath}-800.jpg 800w, ${basePath}-1200.jpg 1200w, ${basePath}-1600.jpg 1600w`
88
+ }
89
+
90
+ return (
91
+ <div className="min-h-screen mb-16 bg-surface-primary">
92
+ <main className="w-full">
93
+ {/* Hero (injectable slot) */}
94
+ <div className="mt-14 md:mt-16">
95
+ <Hero
96
+ image={getPhoto(0)}
97
+ srcSet={getSrcSet(photos[0])}
98
+ alt={`${displayName} showcase`}
99
+ imageOpacity={100}
100
+ >
101
+ <div className="flex flex-col items-center text-center gap-4 md:gap-6 px-4 md:px-6 py-6 md:py-8 rounded-[2px]" style={{ backgroundColor: 'color-mix(in srgb, var(--kol-surface-primary) 80%, transparent)', backdropFilter: 'blur(1px)' }}>
102
+ <span
103
+ className={`${(displayName === 'Málrómur' || displayName === 'Tröllatunga') ? 'text-[48px] sm:text-[64px] md:text-[88px] lg:text-[120px]' : 'text-[56px] sm:text-[80px] md:text-[110px] lg:text-[144px]'} block text-auto leading-none ${fontStyle === 'italic' ? 'italic' : ''}`.trim()}
104
+ style={{ fontFamily, fontStyle: fontStyle || 'normal', fontWeight: 400 }}
105
+ >
106
+ {displayName}
107
+ </span>
108
+ <span className="kol-mono-xs text-fg-64">{category}</span>
109
+ <p className="kol-mono-xs text-auto max-w-[480px] md:max-w-[600px]">{description}</p>
110
+ <LinkEl {...linkPropsFor('/foundry/licensing')}>
111
+ <Button variant="primary" size="sm">Download Font</Button>
112
+ </LinkEl>
113
+ </div>
114
+ </Hero>
115
+ </div>
116
+
117
+ <div className="breakpoint-padding">
118
+ {/* Section 1: Styles */}
119
+ <TypefaceStyleSection typeface={typeface} />
120
+
121
+ {/* Image Section 2 */}
122
+ <section className="w-full overflow-hidden py-16">
123
+ <div className="max-w-[1400px] mx-auto aspect-[2/1]">
124
+ <div className="w-full h-full bg-surface-secondary rounded border border-fg-08">
125
+ <img
126
+ src={getPhoto(1)}
127
+ srcSet={getSrcSet(photos[1])}
128
+ sizes="(max-width: 1400px) 100vw, 1400px"
129
+ alt={`${displayName} showcase`}
130
+ className="w-full h-full object-cover rounded-[4px]"
131
+ loading="lazy"
132
+ />
133
+ </div>
134
+ </div>
135
+ </section>
136
+
137
+ {/* Section 2: Font Preview */}
138
+ <FontPreviewSection
139
+ fontFamily={fontFamily}
140
+ badgeText={badgeText}
141
+ showDropdown={styles.hasItalic}
142
+ availableWeights={(styles.weights || []).map(w => w.label)}
143
+ initialWeight={(styles.weights || [])[0]?.label || 'Regular'}
144
+ />
145
+
146
+ {/* Image Section 3 */}
147
+ <section className="w-full overflow-hidden py-16">
148
+ <div className="max-w-[1400px] mx-auto aspect-[2/1]">
149
+ <div className="w-full h-full bg-surface-secondary rounded border border-fg-08">
150
+ <img
151
+ src={getPhoto(2)}
152
+ srcSet={getSrcSet(photos[2])}
153
+ sizes="(max-width: 1400px) 100vw, 1400px"
154
+ alt={`${displayName} showcase`}
155
+ className="w-full h-full object-cover rounded-[4px]"
156
+ loading="lazy"
157
+ />
158
+ </div>
159
+ </div>
160
+ </section>
161
+
162
+ {/* Section 3: Variable Font (only for variable fonts) */}
163
+ {showVariableSection && (
164
+ <VariableFontSection
165
+ fontFamily={fontFamily}
166
+ badgeText={badgeText}
167
+ showDropdown={styles.hasItalic}
168
+ />
169
+ )}
170
+
171
+ {/* Section 4: Glyph Metrics Grid */}
172
+ <GlyphMetricsSection
173
+ fontUrlRoman={fontUrlRoman || fontUrl}
174
+ fontUrlItalic={fontUrlItalic || fontUrl}
175
+ fontFamily={fontFamily}
176
+ fontStyle={fontStyle}
177
+ badgeText={badgeText}
178
+ showDropdown={styles.hasItalic}
179
+ hasWeight={styles.hasWeight}
180
+ hasWidth={styles.hasWidth}
181
+ weights={styles.weights || []}
182
+ widths={styles.widths || []}
183
+ />
184
+
185
+ {/* Image Section 4 */}
186
+ <section className="w-full mt-12 py-16 overflow-hidden">
187
+ <div className="max-w-[1400px] mx-auto aspect-[2/1]">
188
+ <div className="w-full h-full bg-surface-secondary rounded border border-fg-08">
189
+ <img
190
+ src={getPhoto(3)}
191
+ srcSet={getSrcSet(photos[3])}
192
+ sizes="(max-width: 1400px) 100vw, 1400px"
193
+ alt={`${displayName} showcase`}
194
+ className="w-full h-full object-cover rounded-[4px]"
195
+ loading="lazy"
196
+ />
197
+ </div>
198
+ </div>
199
+ </section>
200
+
201
+ {/* Image Section 5 */}
202
+ <section className="w-full py-16 overflow-hidden">
203
+ <div className="max-w-[1400px] mx-auto aspect-[2/1]">
204
+ <div className="w-full h-full bg-surface-secondary rounded border border-fg-08">
205
+ <img
206
+ src={getPhoto(4)}
207
+ srcSet={getSrcSet(photos[4])}
208
+ sizes="(max-width: 1400px) 100vw, 1400px"
209
+ alt={`${displayName} showcase`}
210
+ className="w-full h-full object-cover rounded-[4px]"
211
+ loading="lazy"
212
+ />
213
+ </div>
214
+ </div>
215
+ </section>
216
+
217
+ </div>
218
+ </main>
219
+ </div>
220
+ )
221
+ }
222
+
223
+ export default TypefaceSpecimenPage
@@ -0,0 +1,160 @@
1
+ import { useState } from 'react'
2
+ import { Slider } from '@kolkrabbi/kol-component'
3
+
4
+ /**
5
+ * TypefaceVariablePreview — interactive preview for a single typeface weight.
6
+ * List view: editable specimen text with Size / Leading / Spacing sliders. Card
7
+ * view: static metrics readout. Used in the "By Typeface" library filter to
8
+ * compare weights side by side.
9
+ *
10
+ * @param {Object} props
11
+ * @param {Object} props.typeface - Typeface data ({ name, styles, classification, status, year }).
12
+ * @param {string} props.weight - Weight label (e.g. 'Medium', 'Bold').
13
+ * @param {number} props.weightValue - Numeric weight value (200–900).
14
+ * @param {'list'|'card'} props.variant - Display variant (default 'list').
15
+ */
16
+ const TypefaceVariablePreview = ({
17
+ typeface,
18
+ weight = 'Medium',
19
+ weightValue = 400,
20
+ variant = 'list'
21
+ }) => {
22
+ // Font family mapping
23
+ const fontFamilyMap = {
24
+ 'TG Málrómur': 'TGMalromur',
25
+ 'TG Rót': 'TGRoot',
26
+ 'TG Gullhamrar': 'TGGullhamrar',
27
+ 'TG Tröllatunga': 'TGTrollatunga',
28
+ 'TG Dylgjur': 'TGDylgjur'
29
+ }
30
+
31
+ const fontFamily = fontFamilyMap[typeface.name] || 'TGMalromur'
32
+ const isItalic = typeface.name === 'TG Málrómur' || typeface.name === 'TG Dylgjur'
33
+
34
+ // State for interactive controls
35
+ const [size, setSize] = useState(64)
36
+ const [leading, setLeading] = useState(10) // 0-50 range (maps to 90-140%)
37
+ const [spacing, setSpacing] = useState(0)
38
+ const [previewText, setPreviewText] = useState('Rennimjúkt eðal flauel, duft slæðist, silkislaufa')
39
+
40
+ // Card View - Static information display
41
+ if (variant === 'card') {
42
+ return (
43
+ <div className="bg-surface-primary border border-fg-08 rounded-sm p-6 h-full">
44
+ <div className="space-y-4">
45
+ {/* Header */}
46
+ <div>
47
+ <h3 className="kol-heading-sm mb-1">{typeface.name} — {weight}</h3>
48
+ <p className="kol-mono-xs text-fg-64">{typeface.styles}</p>
49
+ </div>
50
+
51
+ {/* Metrics Grid */}
52
+ <div className="grid grid-cols-2 gap-3 pt-4">
53
+ <div>
54
+ <span className="kol-mono-xs text-fg-64">Classification</span>
55
+ <p className="kol-mono-sm text-auto">{typeface.classification}</p>
56
+ </div>
57
+ <div>
58
+ <span className="kol-mono-xs text-fg-64">Weight</span>
59
+ <p className="kol-mono-sm text-auto">{weight} ({weightValue})</p>
60
+ </div>
61
+ <div>
62
+ <span className="kol-mono-xs text-fg-64">Status</span>
63
+ <p className="kol-mono-sm text-auto">{typeface.status}</p>
64
+ </div>
65
+ <div>
66
+ <span className="kol-mono-xs text-fg-64">Year</span>
67
+ <p className="kol-mono-sm text-auto">{typeface.year}</p>
68
+ </div>
69
+ </div>
70
+
71
+ {/* Typography Metrics - Placeholder for future implementation */}
72
+ <div className="pt-4 border-t border-fg-08">
73
+ <h4 className="kol-mono-xs text-fg-64 mb-2">Typography Metrics</h4>
74
+ <div className="grid grid-cols-2 gap-2 kol-mono-xs text-fg-64">
75
+ <div>Baseline: —</div>
76
+ <div>x-height: —</div>
77
+ <div>Cap height: —</div>
78
+ <div>Ascender: —</div>
79
+ <div>Descender: —</div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ // List View - Interactive preview with controls
88
+ return (
89
+ <div className="self-stretch min-h-40 p-6 rounded border border-fg-16 flex flex-col justify-start items-start gap-6 overflow-hidden bg-surface-primary">
90
+ {/* Header Row with Controls */}
91
+ <div className="self-stretch flex justify-between items-center">
92
+ {/* Left: Typeface Name & Info */}
93
+ <div className="w-64 flex justify-start items-start gap-6">
94
+ <div className="flex-1 flex flex-col justify-start items-start gap-3">
95
+ <div className="self-stretch flex flex-col justify-start items-start gap-2">
96
+ <div className="self-stretch kol-mono-sm uppercase">
97
+ {typeface.name} — {weight}
98
+ </div>
99
+ <div className="self-stretch kol-mono-xs text-fg-64">
100
+ {typeface.styles}
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ {/* Right: Three Sliders Horizontal */}
107
+ <div className="w-[960px] flex justify-start items-start gap-8">
108
+ <Slider
109
+ label="Size"
110
+ min={12}
111
+ max={200}
112
+ value={size}
113
+ onChange={setSize}
114
+ variant="minimal"
115
+ className="flex-1"
116
+ />
117
+ <Slider
118
+ label="Leading"
119
+ min={0}
120
+ max={50}
121
+ value={leading}
122
+ onChange={setLeading}
123
+ variant="minimal"
124
+ className="flex-1"
125
+ formatValue={(val) => 90 + val}
126
+ />
127
+ <Slider
128
+ label="Spacing"
129
+ min={-50}
130
+ max={50}
131
+ value={spacing}
132
+ onChange={setSpacing}
133
+ variant="minimal"
134
+ className="flex-1"
135
+ />
136
+ </div>
137
+ </div>
138
+
139
+ {/* Preview Text Row */}
140
+ <div className="self-stretch rounded flex justify-start items-center">
141
+ <input
142
+ type="text"
143
+ value={previewText}
144
+ onChange={(e) => setPreviewText(e.target.value)}
145
+ className="flex-1 self-stretch justify-start bg-transparent text-auto font-black leading-[52px] outline-none border-none placeholder:text-auto"
146
+ style={{
147
+ fontFamily: fontFamily,
148
+ fontStyle: isItalic ? 'italic' : 'normal',
149
+ fontWeight: weightValue,
150
+ fontSize: `${size}px`,
151
+ lineHeight: `${90 + leading}%`,
152
+ letterSpacing: `${spacing}px`
153
+ }}
154
+ />
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ export default TypefaceVariablePreview
package/src/index.js CHANGED
@@ -1,21 +1,30 @@
1
1
  /**
2
- * @kol/component foundry sub-barrel — the type-specimen layer (the DS answer to
3
- * the monorepo's `@kol/ui/foundry`). Exposed as the `./foundry` package subpath
4
- * so the showcase specimen set can import these without touching the root
5
- * src/index.js barrel. The root barrel export lines are documented in the p9
6
- * wiring notes for a central merge.
2
+ * @kolkrabbi/kol-foundry — the type-specimen apparatus. Every export renders,
3
+ * inspects, or manipulates a live font (see COMPONENTS.md for the membership
4
+ * test). Data is consumer-injected; shared primitives stay in @kolkrabbi/kol-component.
7
5
  */
8
6
 
9
- // molecule (shared section header)
7
+ // section scaffold
10
8
  export { default as SpecimenSectionHeader } from './SpecimenSectionHeader.jsx'
11
9
 
12
- // organisms (specimen sections)
10
+ // specimen tools — one typeface, inspected
13
11
  export { default as TypefaceHero } from './TypefaceHero.jsx'
14
- export { default as VariableFontSection } from './VariableFontSection.jsx'
15
- export { default as GlyphMetricsGrid } from './GlyphMetricsGrid.jsx'
16
12
  export { default as TypefaceStyleSection } from './TypefaceStyleSection.jsx'
17
13
  export { default as FontPreviewSection } from './FontPreviewSection.jsx'
14
+ export { default as VariableFontSection } from './VariableFontSection.jsx'
15
+ export { default as GlyphMetricsGrid } from './GlyphMetricsGrid.jsx'
16
+ export { default as GlyphMetricsSection } from './GlyphMetricsSection.jsx'
18
17
  export { default as FoundryCharacterSets } from './FoundryCharacterSets.jsx'
19
18
 
19
+ // catalog — the specimen collection
20
+ export { default as TypefaceLibraryGrid } from './TypefaceLibraryGrid.jsx'
21
+ export { default as TypefaceLibraryGridWithVariables } from './TypefaceLibraryGridWithVariables.jsx'
22
+ export { default as TypefaceLibraryItem } from './TypefaceLibraryItem.jsx'
23
+ export { default as TypefaceVariablePreview } from './TypefaceVariablePreview.jsx'
24
+
25
+ // reference composition (severed page — data via props, no router/SEO/data-fetch)
26
+ export { default as TypefaceSpecimenPage } from './TypefaceSpecimenPage.jsx'
27
+
20
28
  // data
21
29
  export { glyphSets, glyphCategories, SPECIMEN_SAMPLE_TEXT } from './glyphData.js'
30
+ export { typefaceConfig, getTypefaceConfig, getAllTypefaceIds, getAllTypefaces } from './typefaceConfig.js'
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Typeface Configuration — bundled default fixture for the foundry specimen
3
+ * layer. Ported verbatim from the monorepo `apps/web` foundry data so the DS
4
+ * package carries a ready-to-render default set (Málrómur, Rót, Dylgjur,
5
+ * Gullhamrar, Tröllatunga). Consumers can ignore this and inject their own
6
+ * typeface objects via props — nothing here is load-bearing beyond a default.
7
+ *
8
+ * Font URLs are relative (`/fonts/…`); a consumer serves the actual files.
9
+ */
10
+
11
+ const cdnBase = 'https://f005.backblazeb2.com/file/kolkrabbi/website/asset-library/foundry'
12
+
13
+ export const typefaceConfig = {
14
+ malromur: {
15
+ id: 'malromur',
16
+ name: 'Málrómur',
17
+ displayName: 'Málrómur',
18
+ fontFamily: 'TGMalromur',
19
+ fontUrl: '/fonts/TGMalromurItalicVF.ttf',
20
+ fontUrlRoman: '/fonts/TGMalromurRomanVF.ttf',
21
+ fontUrlItalic: '/fonts/TGMalromurItalicVF.ttf',
22
+ fontStyle: 'italic',
23
+ category: 'Variable Font',
24
+ description: 'A contemporary italic variable font for editorial design',
25
+ badgeText: 'Málrómur',
26
+ specimenLink: '/foundry/specimen/malromur',
27
+
28
+ photos: [
29
+ `${cdnBase}/foundry-typefaces/01-malromur/typefaces-malromur/01-typefaces-hero/typefaces-hero-1200.jpg`,
30
+ `${cdnBase}/foundry-typefaces/01-malromur/typefaces-malromur/02-typefaces-image/typefaces-image-1200.jpg`,
31
+ `${cdnBase}/foundry-typefaces/01-malromur/typefaces-malromur/03-typefaces-image/typefaces-image-1200.jpg`,
32
+ `${cdnBase}/foundry-typefaces/01-malromur/typefaces-malromur/04-typefaces-image/typefaces-image-1200.jpg`,
33
+ `${cdnBase}/foundry-typefaces/01-malromur/typefaces-malromur/05-typefaces-image/typefaces-image-1200.jpg`
34
+ ],
35
+
36
+ // Style section config
37
+ styles: {
38
+ hasWeight: true,
39
+ hasWidth: false,
40
+ hasItalic: true,
41
+ defaultStyle: 'italic',
42
+ weights: [
43
+ { label: 'Thin', weight: 100 },
44
+ { label: 'Extralight', weight: 200 },
45
+ { label: 'Light', weight: 300 },
46
+ { label: 'Regular', weight: 400 },
47
+ { label: 'Medium', weight: 500 },
48
+ { label: 'Semibold', weight: 600 },
49
+ { label: 'Bold', weight: 700 },
50
+ { label: 'Extrabold', weight: 800 }
51
+ ]
52
+ }
53
+ },
54
+
55
+ rot: {
56
+ id: 'rot',
57
+ name: 'Rót',
58
+ displayName: 'Rót',
59
+ fontFamily: 'TGRoot',
60
+ fontUrl: '/fonts/TGRotVF.ttf',
61
+ fontStyle: 'normal',
62
+ category: 'Variable Font',
63
+ description: 'A precise geometric sans serif with variable weight and width axes',
64
+ badgeText: 'Rót Aa',
65
+ specimenLink: '/foundry/specimen/rot',
66
+
67
+ photos: [
68
+ `${cdnBase}/foundry-typefaces/02-raetur/typefaces-raetur/01-typefaces-hero/typefaces-hero-1200.jpg`,
69
+ `${cdnBase}/foundry-typefaces/02-raetur/typefaces-raetur/02-typefaces-image/typefaces-image-1200.jpg`,
70
+ `${cdnBase}/foundry-typefaces/02-raetur/typefaces-raetur/03-typefaces-image/typefaces-image-1200.jpg`,
71
+ `${cdnBase}/foundry-typefaces/02-raetur/typefaces-raetur/04-typefaces-image/typefaces-image-1200.jpg`,
72
+ `${cdnBase}/foundry-typefaces/02-raetur/typefaces-raetur/05-typefaces-image/typefaces-image-1200.jpg`
73
+ ],
74
+
75
+ styles: {
76
+ hasWeight: true,
77
+ hasWidth: true,
78
+ hasItalic: false,
79
+ defaultStyle: 'weight',
80
+ weights: [
81
+ { label: 'Thin', weight: 100 },
82
+ { label: 'Extralight', weight: 200 },
83
+ { label: 'Light', weight: 300 },
84
+ { label: 'Regular', weight: 400 },
85
+ { label: 'Medium', weight: 500 },
86
+ { label: 'Semibold', weight: 600 },
87
+ { label: 'Bold', weight: 700 },
88
+ { label: 'Extrabold', weight: 800 },
89
+ { label: 'Black', weight: 900 }
90
+ ],
91
+ widths: [
92
+ { label: 'Narrow', width: 100 },
93
+ { label: 'Semi-Narrow', width: 175 },
94
+ { label: 'Normal', width: 250 },
95
+ { label: 'Semi-Extended', width: 325 },
96
+ { label: 'Extended', width: 400 }
97
+ ]
98
+ }
99
+ },
100
+
101
+ dylgjur: {
102
+ id: 'dylgjur',
103
+ name: 'Dylgjur',
104
+ displayName: 'Dylgjur',
105
+ fontFamily: 'TGDylgjur',
106
+ fontUrl: '/fonts/TGDylgjur-Regular.otf',
107
+ fontStyle: 'normal',
108
+ category: 'Display Font',
109
+ description: 'Sharp angles and pointed character for critical discourse',
110
+ badgeText: 'Dylgjur Aa',
111
+ specimenLink: '/foundry/specimen/dylgjur',
112
+
113
+ photos: [
114
+ `${cdnBase}/foundry-typefaces/03-dylgjur/typefaces-dylgjur/01-typefaces-hero/typefaces-hero-1200.jpg`,
115
+ `${cdnBase}/foundry-typefaces/03-dylgjur/typefaces-dylgjur/02-typefaces-image/typefaces-image-1200.jpg`,
116
+ `${cdnBase}/foundry-typefaces/03-dylgjur/typefaces-dylgjur/03-typefaces-image/typefaces-image-1200.jpg`,
117
+ `${cdnBase}/foundry-typefaces/03-dylgjur/typefaces-dylgjur/04-typefaces-image/typefaces-image-1200.jpg`,
118
+ `${cdnBase}/foundry-typefaces/03-dylgjur/typefaces-dylgjur/05-typefaces-image/typefaces-image-1200.jpg`
119
+ ],
120
+
121
+ styles: {
122
+ hasWeight: false,
123
+ hasWidth: false,
124
+ hasItalic: false,
125
+ weights: [
126
+ { label: 'Regular', weight: 400 }
127
+ ]
128
+ }
129
+ },
130
+
131
+ gullhamrar: {
132
+ id: 'gullhamrar',
133
+ name: 'Gullhamrar',
134
+ displayName: 'Gullhamrar',
135
+ fontFamily: 'TGGullhamrar',
136
+ fontUrl: '/fonts/TGGullhamrarVF.ttf',
137
+ fontStyle: 'normal',
138
+ category: 'Variable Font',
139
+ description: 'Variable weight typeface with warm, graceful forms',
140
+ badgeText: 'Gullhamrar Aa',
141
+ specimenLink: '/foundry/specimen/gullhamrar',
142
+
143
+ photos: [
144
+ `${cdnBase}/foundry-typefaces/04-gullhamrar/typefaces-gullhamrar/01-typefaces-hero/typefaces-hero-1200.jpg`,
145
+ `${cdnBase}/foundry-typefaces/04-gullhamrar/typefaces-gullhamrar/02-typefaces-image/typefaces-image-1200.jpg`,
146
+ `${cdnBase}/foundry-typefaces/04-gullhamrar/typefaces-gullhamrar/03-typefaces-image/typefaces-image-1200.jpg`,
147
+ `${cdnBase}/foundry-typefaces/04-gullhamrar/typefaces-gullhamrar/04-typefaces-image/typefaces-image-1200.jpg`,
148
+ `${cdnBase}/foundry-typefaces/04-gullhamrar/typefaces-gullhamrar/05-typefaces-image/typefaces-image-1200.jpg`
149
+ ],
150
+
151
+ styles: {
152
+ hasWeight: true,
153
+ hasWidth: false,
154
+ hasItalic: false,
155
+ weights: [
156
+ { label: 'Thin', weight: 100 },
157
+ { label: 'Extralight', weight: 200 },
158
+ { label: 'Light', weight: 300 },
159
+ { label: 'Regular', weight: 400 },
160
+ { label: 'Medium', weight: 500 },
161
+ { label: 'Semibold', weight: 600 },
162
+ { label: 'Bold', weight: 700 },
163
+ { label: 'Extrabold', weight: 800 }
164
+ ]
165
+ }
166
+ },
167
+
168
+ trollatunga: {
169
+ id: 'trollatunga',
170
+ name: 'Tröllatunga',
171
+ displayName: 'Tröllatunga',
172
+ fontFamily: 'TGTrollatunga',
173
+ fontUrl: '/fonts/TGTrollatunga-Regular.otf',
174
+ fontStyle: 'normal',
175
+ category: 'Display Font',
176
+ description: 'Bold expressive display font for impactful messaging',
177
+ badgeText: 'Tröllatunga Aa',
178
+ specimenLink: '/foundry/specimen/trollatunga',
179
+
180
+ photos: [
181
+ `${cdnBase}/foundry-typefaces/05-trollatunga/typefaces-trollatunga/01-typefaces-hero/typefaces-hero-1200.jpg`,
182
+ `${cdnBase}/foundry-typefaces/05-trollatunga/typefaces-trollatunga/02-typefaces-image/typefaces-image-1200.jpg`,
183
+ `${cdnBase}/foundry-typefaces/05-trollatunga/typefaces-trollatunga/03-typefaces-image/typefaces-image-1200.jpg`,
184
+ `${cdnBase}/foundry-typefaces/05-trollatunga/typefaces-trollatunga/04-typefaces-image/typefaces-image-1200.jpg`,
185
+ `${cdnBase}/foundry-typefaces/05-trollatunga/typefaces-trollatunga/05-typefaces-image/typefaces-image-1200.jpg`
186
+ ],
187
+
188
+ styles: {
189
+ hasWeight: false,
190
+ hasWidth: false,
191
+ hasItalic: false,
192
+ weights: [
193
+ { label: 'Regular', weight: 400 }
194
+ ]
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Helper to get typeface config by ID
201
+ * @param {string} id - Typeface ID (malromur, rot, dylgjur, etc.)
202
+ * @returns {object} Typeface configuration object
203
+ */
204
+ export function getTypefaceConfig(id) {
205
+ const config = typefaceConfig[id]
206
+ if (!config) {
207
+ console.warn(`Typeface config not found for: ${id}`)
208
+ return typefaceConfig.malromur // fallback
209
+ }
210
+ return config
211
+ }
212
+
213
+ /**
214
+ * Get all typeface IDs
215
+ * @returns {string[]} Array of typeface IDs
216
+ */
217
+ export function getAllTypefaceIds() {
218
+ return Object.keys(typefaceConfig)
219
+ }
220
+
221
+ /**
222
+ * Get all typefaces
223
+ * @returns {object[]} Array of typeface configuration objects
224
+ */
225
+ export function getAllTypefaces() {
226
+ return Object.values(typefaceConfig)
227
+ }