@kolkrabbi/kol-foundry 0.1.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 +24 -0
- package/package.json +49 -0
- package/src/FontPreviewSection.jsx +187 -0
- package/src/FoundryCharacterSets.jsx +113 -0
- package/src/GlyphMetricsGrid.jsx +335 -0
- package/src/SpecimenSectionHeader.jsx +89 -0
- package/src/TypefaceHero.jsx +107 -0
- package/src/TypefaceStyleSection.jsx +163 -0
- package/src/VariableFontSection.jsx +158 -0
- package/src/glyphData.js +30 -0
- package/src/index.js +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @kolkrabbi/kol-foundry
|
|
2
|
+
|
|
3
|
+
The KOL **type-foundry system** — the type-specimen apparatus, lifted out of `@kolkrabbi/kol-component` (was the `/foundry` subpath) into its own package. It's a distinct content domain (typefaces, glyph metrics, variable-font axes — not Sanity CMS) with its own consumer (kolkrabbi.io/foundry) and versioning cadence.
|
|
4
|
+
|
|
5
|
+
## What's in the box
|
|
6
|
+
|
|
7
|
+
| Export | What |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `TypefaceHero` | specimen hero — typeface name, styles, sample |
|
|
10
|
+
| `VariableFontSection` | live weight/axis playground (`useAxisAnimation`) |
|
|
11
|
+
| `GlyphMetricsGrid` | parsed-metric glyph inspector |
|
|
12
|
+
| `FoundryCharacterSets` | character-set browser |
|
|
13
|
+
| `TypefaceStyleSection` | per-style specimen block |
|
|
14
|
+
| `FontPreviewSection` | size-ladder / preview control |
|
|
15
|
+
| `SpecimenSectionHeader` | shared section header |
|
|
16
|
+
| `glyphSets`, `glyphCategories`, `SPECIMEN_SAMPLE_TEXT` | glyph data |
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { TypefaceHero, VariableFontSection } from '@kolkrabbi/kol-foundry'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
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"`).
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kolkrabbi/kol-foundry",
|
|
3
|
+
"version": "0.1.0",
|
|
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}.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.js",
|
|
9
|
+
"module": "./src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@kolkrabbi/kol-component": "0.7.0",
|
|
15
|
+
"@kolkrabbi/kol-icons": "0.5.0",
|
|
16
|
+
"@kolkrabbi/kol-theme": "0.7.1"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": "^18.3.0 || ^19.0.0",
|
|
20
|
+
"react-dom": "^18.3.0 || ^19.0.0",
|
|
21
|
+
"opentype.js": "^1.3.4"
|
|
22
|
+
},
|
|
23
|
+
"peerDependenciesMeta": {
|
|
24
|
+
"opentype.js": {
|
|
25
|
+
"optional": true
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"keywords": [
|
|
33
|
+
"kol",
|
|
34
|
+
"kolkrabbi",
|
|
35
|
+
"design-system",
|
|
36
|
+
"foundry",
|
|
37
|
+
"typeface",
|
|
38
|
+
"specimen",
|
|
39
|
+
"type"
|
|
40
|
+
],
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
|
|
44
|
+
"directory": "packages/foundry"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Dropdown } from '@kolkrabbi/kol-component'
|
|
3
|
+
import { Slider } from '@kolkrabbi/kol-component'
|
|
4
|
+
import SpecimenSectionHeader from './SpecimenSectionHeader.jsx'
|
|
5
|
+
import { SPECIMEN_SAMPLE_TEXT } from './glyphData.js'
|
|
6
|
+
|
|
7
|
+
/* taxonomy-ok: organism — nests Dropdown, Slider, SpecimenSectionHeader
|
|
8
|
+
* (relative imports) plus a same-file FontPreviewItem. */
|
|
9
|
+
|
|
10
|
+
const WEIGHT_VALUES = {
|
|
11
|
+
Thin: 100,
|
|
12
|
+
Extralight: 200,
|
|
13
|
+
Light: 300,
|
|
14
|
+
Regular: 400,
|
|
15
|
+
Medium: 500,
|
|
16
|
+
Semibold: 600,
|
|
17
|
+
Bold: 700,
|
|
18
|
+
Extrabold: 800,
|
|
19
|
+
Black: 900,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* FontPreviewItem (same-file) — one preview block: a weight dropdown + Size /
|
|
24
|
+
* Leading / Spacing sliders over an editable, in-family sample. Weight is
|
|
25
|
+
* seeded from the section header (re-syncs when the parent's selection changes);
|
|
26
|
+
* size/leading/spacing are local. Leading maps 0–50 → 90–140% line-height.
|
|
27
|
+
*/
|
|
28
|
+
function FontPreviewItem({
|
|
29
|
+
fontFamily,
|
|
30
|
+
fontStyle = 'normal',
|
|
31
|
+
text = SPECIMEN_SAMPLE_TEXT,
|
|
32
|
+
initialWeight = 'Regular',
|
|
33
|
+
initialSize = 64,
|
|
34
|
+
initialLeading = 10,
|
|
35
|
+
lineClamp = null,
|
|
36
|
+
availableWeights,
|
|
37
|
+
}) {
|
|
38
|
+
const [size, setSize] = useState(initialSize)
|
|
39
|
+
const [leading, setLeading] = useState(initialLeading)
|
|
40
|
+
const [spacing, setSpacing] = useState(0)
|
|
41
|
+
const [selectedWeight, setSelectedWeight] = useState(initialWeight)
|
|
42
|
+
const editableRef = useRef(null)
|
|
43
|
+
|
|
44
|
+
useEffect(() => setSelectedWeight(initialWeight), [initialWeight])
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (editableRef.current && editableRef.current.textContent !== text) {
|
|
47
|
+
editableRef.current.textContent = text
|
|
48
|
+
}
|
|
49
|
+
}, [text])
|
|
50
|
+
|
|
51
|
+
const weightOptions = availableWeights.map((label) => ({ label, value: label }))
|
|
52
|
+
const numericWeight = WEIGHT_VALUES[selectedWeight] || 400
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="self-stretch min-h-40 p-6 rounded border border-fg-16 flex flex-col justify-start items-start gap-6 bg-surface-primary">
|
|
56
|
+
<div className="self-stretch flex justify-start items-start gap-8">
|
|
57
|
+
<div className="flex-shrink-0">
|
|
58
|
+
<Dropdown
|
|
59
|
+
options={weightOptions}
|
|
60
|
+
value={selectedWeight}
|
|
61
|
+
onChange={setSelectedWeight}
|
|
62
|
+
variant="minimal"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="flex-1 flex justify-start items-start gap-8">
|
|
66
|
+
<Slider label="Size" min={12} max={200} value={size} onChange={setSize} variant="minimal" className="flex-1" />
|
|
67
|
+
<Slider
|
|
68
|
+
label="Leading"
|
|
69
|
+
min={0}
|
|
70
|
+
max={50}
|
|
71
|
+
value={leading}
|
|
72
|
+
onChange={setLeading}
|
|
73
|
+
variant="minimal"
|
|
74
|
+
className="hidden md:flex flex-1"
|
|
75
|
+
formatValue={(val) => 90 + val}
|
|
76
|
+
/>
|
|
77
|
+
<Slider label="Spacing" min={-50} max={50} value={spacing} onChange={setSpacing} variant="minimal" className="hidden md:flex flex-1" />
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="self-stretch rounded flex justify-start items-start overflow-hidden">
|
|
82
|
+
<div
|
|
83
|
+
ref={editableRef}
|
|
84
|
+
contentEditable
|
|
85
|
+
suppressContentEditableWarning
|
|
86
|
+
className="flex-1 bg-transparent outline-none border-none text-auto"
|
|
87
|
+
style={{
|
|
88
|
+
fontFamily,
|
|
89
|
+
fontStyle,
|
|
90
|
+
fontWeight: numericWeight,
|
|
91
|
+
fontSize: `${size}px`,
|
|
92
|
+
lineHeight: `${90 + leading}%`,
|
|
93
|
+
letterSpacing: `${spacing}px`,
|
|
94
|
+
wordWrap: 'break-word',
|
|
95
|
+
overflowWrap: 'break-word',
|
|
96
|
+
whiteSpace: 'normal',
|
|
97
|
+
hyphens: 'none',
|
|
98
|
+
maxWidth: '100%',
|
|
99
|
+
...(lineClamp && {
|
|
100
|
+
display: '-webkit-box',
|
|
101
|
+
WebkitLineClamp: lineClamp,
|
|
102
|
+
WebkitBoxOrient: 'vertical',
|
|
103
|
+
overflow: 'hidden',
|
|
104
|
+
}),
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DEFAULT_WEIGHTS = [
|
|
113
|
+
'Thin', 'Extralight', 'Light', 'Regular', 'Medium', 'Semibold', 'Bold', 'Extrabold', 'Black',
|
|
114
|
+
]
|
|
115
|
+
const SIZE_LADDER = [
|
|
116
|
+
{ size: 96, lines: 1 },
|
|
117
|
+
{ size: 64, lines: 3 },
|
|
118
|
+
{ size: 48, lines: 4 },
|
|
119
|
+
{ size: 24, lines: 5, leading: 50 },
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* FontPreviewSection — a stacked multi-size preview: the shared header (weight +
|
|
124
|
+
* Roman/Italic dropdowns) above a ladder of preview blocks at 96 / 64 / 48 / 24
|
|
125
|
+
* px, each pre-seeded with the section's selected weight + italic state. Read a
|
|
126
|
+
* family from display down to body size at a chosen weight.
|
|
127
|
+
*
|
|
128
|
+
* Font-agnostic: the monorepo's TGMalromur/Málrómur defaults are dropped —
|
|
129
|
+
* `fontFamily` + `badgeText` are prop-driven. The size ladder + weight ramp are
|
|
130
|
+
* sensible defaults, overridable via `sizes` / `availableWeights`.
|
|
131
|
+
*
|
|
132
|
+
* Text casing: badgeText, weight labels and style labels render verbatim.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} props.fontFamily - Family rendered in every preview item.
|
|
135
|
+
* @param {string} props.badgeText - Header title.
|
|
136
|
+
* @param {boolean} props.showDropdown - Render the style dropdown; seeds italic (default true).
|
|
137
|
+
* @param {string[]} props.availableWeights - Weight dropdown options (default 9-stop ramp).
|
|
138
|
+
* @param {string} props.initialWeight - Initially selected weight (default 'Regular').
|
|
139
|
+
* @param {Array<{size,lines,leading?}>} props.sizes - Size ladder (default 96/64/48/24).
|
|
140
|
+
*/
|
|
141
|
+
const FontPreviewSection = ({
|
|
142
|
+
fontFamily = 'system-ui, sans-serif',
|
|
143
|
+
badgeText = 'Preview',
|
|
144
|
+
showDropdown = true,
|
|
145
|
+
availableWeights = DEFAULT_WEIGHTS,
|
|
146
|
+
initialWeight = 'Regular',
|
|
147
|
+
sizes = SIZE_LADDER,
|
|
148
|
+
}) => {
|
|
149
|
+
const [selectedStyleVariant, setSelectedStyleVariant] = useState(showDropdown ? 'italic' : 'roman')
|
|
150
|
+
const [selectedWeight, setSelectedWeight] = useState(initialWeight)
|
|
151
|
+
const isItalic = selectedStyleVariant === 'italic'
|
|
152
|
+
const weightOptions = availableWeights.map((label) => ({ label, value: label }))
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<section className="w-full py-12 lg:py-16">
|
|
156
|
+
<div className="max-w-[1400px] mx-auto flex flex-col gap-8">
|
|
157
|
+
<SpecimenSectionHeader
|
|
158
|
+
selectedStyle={selectedStyleVariant}
|
|
159
|
+
onStyleChange={setSelectedStyleVariant}
|
|
160
|
+
showDropdown={showDropdown}
|
|
161
|
+
badgeText={badgeText}
|
|
162
|
+
icon="foundation"
|
|
163
|
+
size="sm"
|
|
164
|
+
selectedWeight={selectedWeight}
|
|
165
|
+
onWeightChange={setSelectedWeight}
|
|
166
|
+
showWeightDropdown={availableWeights.length > 0}
|
|
167
|
+
weightOptions={weightOptions}
|
|
168
|
+
/>
|
|
169
|
+
|
|
170
|
+
{sizes.map(({ size, lines, leading }) => (
|
|
171
|
+
<FontPreviewItem
|
|
172
|
+
key={size}
|
|
173
|
+
fontFamily={fontFamily}
|
|
174
|
+
fontStyle={isItalic ? 'italic' : 'normal'}
|
|
175
|
+
initialWeight={selectedWeight}
|
|
176
|
+
availableWeights={availableWeights}
|
|
177
|
+
initialSize={size}
|
|
178
|
+
initialLeading={leading}
|
|
179
|
+
lineClamp={lines}
|
|
180
|
+
/>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
</section>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default FontPreviewSection
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Button } from '@kolkrabbi/kol-component'
|
|
3
|
+
import SpecimenSectionHeader from './SpecimenSectionHeader.jsx'
|
|
4
|
+
import { glyphSets, glyphCategories } from './glyphData.js'
|
|
5
|
+
|
|
6
|
+
/* taxonomy-ok: organism — nests Button + SpecimenSectionHeader (relative
|
|
7
|
+
* imports) plus same-file GlyphCategory / GlyphItem. */
|
|
8
|
+
|
|
9
|
+
/** GlyphItem (same-file) — one bordered glyph cell that flips to the inverse
|
|
10
|
+
* surface on hover. Display-only (character sets aren't selectable). */
|
|
11
|
+
function GlyphItem({ glyph, fontFamily, fontStyle }) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className="aspect-square border rounded-lg flex items-center justify-center text-3xl leading-none transition-colors duration-300 w-16 h-16 hover:bg-surface-inverse"
|
|
15
|
+
style={{ borderColor: 'var(--kol-border-default)', fontFamily, fontStyle }}
|
|
16
|
+
>
|
|
17
|
+
{glyph}
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** GlyphCategory (same-file) — a labeled category title over a wrap grid of
|
|
23
|
+
* GlyphItems, all rendered in the chosen family/style. */
|
|
24
|
+
function GlyphCategory({ title, glyphs, fontFamily, fontStyle, className = '' }) {
|
|
25
|
+
return (
|
|
26
|
+
<div className={`w-full flex flex-col gap-4 ${className}`.trim()}>
|
|
27
|
+
<h3 className="kol-mono-12">{title}</h3>
|
|
28
|
+
<div className="flex flex-wrap gap-4 w-full">
|
|
29
|
+
{glyphs?.map((glyph, i) => (
|
|
30
|
+
<GlyphItem key={i} glyph={glyph} fontFamily={fontFamily} fontStyle={fontStyle} />
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* FoundryCharacterSets — the character-set browser: the shared header ("Character
|
|
39
|
+
* Set" + Roman/Italic dropdown) above a stack of glyph categories rendered in
|
|
40
|
+
* the chosen family/style. Collapsed by default to the first two categories
|
|
41
|
+
* under a fade with a "Show All Glyphs" reveal; clicking expands to every
|
|
42
|
+
* category.
|
|
43
|
+
*
|
|
44
|
+
* Light-theme fix ([source-bug]): the monorepo faded with a hardcoded
|
|
45
|
+
* `rgba(18,18,21,…)` — the app's literal dark background baked in, which broke
|
|
46
|
+
* in light theme. Here the mask runs `transparent → var(--kol-surface-primary)`
|
|
47
|
+
* so it matches whatever surface it sits on, in either theme. `fontFamily` is
|
|
48
|
+
* prop-driven (no brand default).
|
|
49
|
+
*
|
|
50
|
+
* Text casing: label, category titles and the reveal button render verbatim.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} props.fontFamily - Family every glyph renders in.
|
|
53
|
+
* @param {boolean} props.showDropdown - Render the Roman/Italic dropdown (default true).
|
|
54
|
+
* @param {number} props.collapsedCount - Categories shown when collapsed (default 2).
|
|
55
|
+
*/
|
|
56
|
+
const FoundryCharacterSets = ({
|
|
57
|
+
fontFamily = 'system-ui, sans-serif',
|
|
58
|
+
showDropdown = true,
|
|
59
|
+
collapsedCount = 2,
|
|
60
|
+
}) => {
|
|
61
|
+
const [selectedStyle, setSelectedStyle] = useState('italic')
|
|
62
|
+
const [showAll, setShowAll] = useState(false)
|
|
63
|
+
|
|
64
|
+
const visibleCategories = showAll ? glyphCategories : glyphCategories.slice(0, collapsedCount)
|
|
65
|
+
const fontStyle = selectedStyle === 'italic' ? 'italic' : 'normal'
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<section className="w-full py-12 lg:py-16">
|
|
69
|
+
<div className="max-w-[1400px] mx-auto flex flex-col gap-8">
|
|
70
|
+
<SpecimenSectionHeader
|
|
71
|
+
label="Character Set"
|
|
72
|
+
size="sm"
|
|
73
|
+
selectedStyle={selectedStyle}
|
|
74
|
+
onStyleChange={setSelectedStyle}
|
|
75
|
+
showDropdown={showDropdown}
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<div className="w-full flex flex-col gap-12 relative">
|
|
79
|
+
{visibleCategories.map((category, index) => (
|
|
80
|
+
<GlyphCategory
|
|
81
|
+
key={category.key}
|
|
82
|
+
title={category.title}
|
|
83
|
+
glyphs={glyphSets[category.key]}
|
|
84
|
+
fontFamily={fontFamily}
|
|
85
|
+
fontStyle={fontStyle}
|
|
86
|
+
className={!showAll && index === collapsedCount - 1 ? 'relative' : ''}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
|
|
90
|
+
{!showAll && (
|
|
91
|
+
<>
|
|
92
|
+
{/* Theme-aware fade (fixes the hardcoded rgba(18,18,21) dark mask). */}
|
|
93
|
+
<div
|
|
94
|
+
className="absolute bottom-0 left-0 right-0 h-64 pointer-events-none"
|
|
95
|
+
style={{
|
|
96
|
+
background:
|
|
97
|
+
'linear-gradient(to bottom, transparent 13%, var(--kol-surface-primary) 86%)',
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
<div className="absolute bottom-0 left-0 right-0 flex justify-center">
|
|
101
|
+
<Button onClick={() => setShowAll(true)} variant="outline">
|
|
102
|
+
Show All Glyphs
|
|
103
|
+
</Button>
|
|
104
|
+
</div>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default FoundryCharacterSets
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Tag } from '@kolkrabbi/kol-component'
|
|
3
|
+
import { glyphSets } from './glyphData.js'
|
|
4
|
+
|
|
5
|
+
/* taxonomy-ok: organism — nests DS Tag (relative import) and owns font I/O +
|
|
6
|
+
* parsed-metric geometry. */
|
|
7
|
+
|
|
8
|
+
/* ---------------------------------------------------------------------------
|
|
9
|
+
* FontLoader (same-file) — fetch → FontFace inject → best-effort opentype parse.
|
|
10
|
+
*
|
|
11
|
+
* The font face is ALWAYS injected (under a unique family name) so the big
|
|
12
|
+
* glyph + grid cells render the real file even when metric parsing is
|
|
13
|
+
* unavailable. opentype.js is loaded via a DYNAMIC import so a consumer that
|
|
14
|
+
* hasn't installed the peer dep simply gets no parsed metrics (the grid renders
|
|
15
|
+
* without the baseline/x-height/cap/ascender/descender overlay) instead of a
|
|
16
|
+
* hard crash. Folds in the fallback-chain metric extraction the monorepo's
|
|
17
|
+
* inline overlay added (os2 ?? hhea ?? literal), so incomplete fonts don't throw.
|
|
18
|
+
* ------------------------------------------------------------------------- */
|
|
19
|
+
class FontLoader {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.callbacks = options
|
|
22
|
+
this.family = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async loadFont(buffer, filename) {
|
|
26
|
+
// Inject the face first — this never needs opentype and guarantees the
|
|
27
|
+
// glyph renders in the real font.
|
|
28
|
+
const uniqueFontName = `KolFoundryFont_${Date.now()}`
|
|
29
|
+
try {
|
|
30
|
+
const fontFace = new FontFace(uniqueFontName, buffer)
|
|
31
|
+
await fontFace.load()
|
|
32
|
+
document.fonts.add(fontFace)
|
|
33
|
+
this.family = uniqueFontName
|
|
34
|
+
} catch (err) {
|
|
35
|
+
this.callbacks.onError?.(err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Best-effort metric parse. Dynamic import degrades gracefully when the
|
|
39
|
+
// opentype.js peer dep is absent.
|
|
40
|
+
let font = null
|
|
41
|
+
try {
|
|
42
|
+
const mod = await import('opentype.js')
|
|
43
|
+
const parse = mod.parse || mod.default?.parse || mod.default
|
|
44
|
+
font = parse(buffer)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// No metrics — the overlay just won't draw. Not fatal.
|
|
47
|
+
this.callbacks.onError?.(err)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.callbacks.onFontLoaded?.({ font, fontFamily: this.family, filename })
|
|
51
|
+
return { font, fontFamily: this.family }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
cleanup() {
|
|
55
|
+
if (typeof document === 'undefined') return
|
|
56
|
+
document.fonts.forEach((f) => {
|
|
57
|
+
if (f.family && f.family.startsWith('KolFoundryFont_')) document.fonts.delete(f)
|
|
58
|
+
})
|
|
59
|
+
this.family = null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* GlyphMetricsGrid — table-style glyph inspector with real, parsed font metrics.
|
|
65
|
+
*
|
|
66
|
+
* One giant glyph carrying a live baseline / x-height / cap-height / ascender /
|
|
67
|
+
* descender overlay drawn from the font's OWN OS/2 + hhea tables, beside two
|
|
68
|
+
* clickable uppercase/lowercase glyph grids and a Unicode / decimal / hex
|
|
69
|
+
* readout. Clicking a cell pins the big glyph; hovering previews it.
|
|
70
|
+
* `variationSettings` feed straight into `font-variation-settings` so the
|
|
71
|
+
* overlay stays correct under a live variable axis driven from the parent.
|
|
72
|
+
*
|
|
73
|
+
* FONT-ASSET CONTRACT: pass a real `.ttf`/`.otf` at `fontUrl` (same-origin, so
|
|
74
|
+
* the fetch + FontFace injection succeed). The metric lines require opentype.js
|
|
75
|
+
* (a peer dep, dynamically imported) — without it the grids still render, just
|
|
76
|
+
* with no overlay. The showcase serves fonts under `/fonts/`; e.g.
|
|
77
|
+
* `/fonts/Right-Grotesk-ttf/PPRightGrotesk-Regular.ttf`.
|
|
78
|
+
*
|
|
79
|
+
* Metric extraction uses fallback chains so incomplete fonts degrade instead of
|
|
80
|
+
* throwing: unitsPerEm ← font.unitsPerEm ?? 1000; ascender ← os2.sTypoAscender
|
|
81
|
+
* ?? hhea.ascender ?? 800; descender ← os2.sTypoDescender ?? hhea.descender ??
|
|
82
|
+
* -200; capHeight ← os2.sCapHeight ?? 700; xHeight ← os2.sxHeight ?? 500.
|
|
83
|
+
*
|
|
84
|
+
* Text casing: metadata labels, grid titles and tab labels render verbatim.
|
|
85
|
+
*
|
|
86
|
+
* @param {Object} props
|
|
87
|
+
* @param {string} props.fontUrl - URL of the font to fetch + parse (required for real metrics).
|
|
88
|
+
* @param {string} props.fontFamily - CSS family fallback until the parsed face is injected.
|
|
89
|
+
* @param {'normal'|'italic'} props.fontStyle - Inline font-style + the Roman/Italic metadata label.
|
|
90
|
+
* @param {string} props.initialGlyph - Initially selected glyph (default 'f').
|
|
91
|
+
* @param {string[]} props.uppercaseGlyphs - Top grid contents.
|
|
92
|
+
* @param {string[]} props.lowercaseGlyphs - Bottom grid contents.
|
|
93
|
+
* @param {Object} props.variationSettings - axis→value map serialized to font-variation-settings.
|
|
94
|
+
*/
|
|
95
|
+
const GlyphMetricsGrid = ({
|
|
96
|
+
fontUrl,
|
|
97
|
+
fontFamily = 'sans-serif',
|
|
98
|
+
fontStyle = 'normal',
|
|
99
|
+
initialGlyph = 'f',
|
|
100
|
+
uppercaseGlyphs = [...glyphSets.uppercase, ...glyphSets.latin1],
|
|
101
|
+
lowercaseGlyphs = [...glyphSets.lowercase, ...glyphSets.latinExtended],
|
|
102
|
+
variationSettings = {},
|
|
103
|
+
}) => {
|
|
104
|
+
const [selectedGlyph, setSelectedGlyph] = useState(initialGlyph)
|
|
105
|
+
const [hoveredGlyph, setHoveredGlyph] = useState(null)
|
|
106
|
+
const [metrics, setMetrics] = useState(null)
|
|
107
|
+
const [fontData, setFontData] = useState(null)
|
|
108
|
+
const [loadedFamily, setLoadedFamily] = useState(null)
|
|
109
|
+
const [activeTab, setActiveTab] = useState('uppercase')
|
|
110
|
+
|
|
111
|
+
const glyphRef = useRef(null)
|
|
112
|
+
const overlayRef = useRef(null)
|
|
113
|
+
|
|
114
|
+
const displayGlyph = hoveredGlyph || selectedGlyph
|
|
115
|
+
const renderFamily = loadedFamily || fontFamily
|
|
116
|
+
|
|
117
|
+
// Serialize the axis map once for the big glyph + every cell.
|
|
118
|
+
const fontVariationSettingsCSS =
|
|
119
|
+
Object.entries(variationSettings)
|
|
120
|
+
.map(([axis, value]) => `"${axis}" ${value}`)
|
|
121
|
+
.join(', ') || 'normal'
|
|
122
|
+
|
|
123
|
+
// Load font + extract metrics (keyed on fontUrl only).
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!fontUrl) return
|
|
126
|
+
const glyphElement = glyphRef.current
|
|
127
|
+
if (!glyphElement) return
|
|
128
|
+
|
|
129
|
+
const loader = new FontLoader({
|
|
130
|
+
onFontLoaded: ({ font, fontFamily: injectedFamily }) => {
|
|
131
|
+
if (injectedFamily) setLoadedFamily(injectedFamily)
|
|
132
|
+
glyphElement.textContent = displayGlyph
|
|
133
|
+
if (!font) return
|
|
134
|
+
|
|
135
|
+
const os2 = font.tables?.os2
|
|
136
|
+
const hhea = font.tables?.hhea
|
|
137
|
+
setFontData({ font })
|
|
138
|
+
setMetrics({
|
|
139
|
+
unitsPerEm: font.unitsPerEm || 1000,
|
|
140
|
+
ascender: os2?.sTypoAscender ?? hhea?.ascender ?? 800,
|
|
141
|
+
descender: os2?.sTypoDescender ?? hhea?.descender ?? -200,
|
|
142
|
+
capHeight: os2?.sCapHeight ?? 700,
|
|
143
|
+
xHeight: os2?.sxHeight ?? 500,
|
|
144
|
+
})
|
|
145
|
+
},
|
|
146
|
+
onError: (err) => console.warn('GlyphMetricsGrid: metrics unavailable', err),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
let cancelled = false
|
|
150
|
+
;(async () => {
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(fontUrl)
|
|
153
|
+
const buffer = await response.arrayBuffer()
|
|
154
|
+
const filename = fontUrl.split('/').pop() || 'font.ttf'
|
|
155
|
+
if (!cancelled) await loader.loadFont(buffer, filename)
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (!cancelled) console.warn('GlyphMetricsGrid: font fetch failed', err)
|
|
158
|
+
}
|
|
159
|
+
})()
|
|
160
|
+
|
|
161
|
+
return () => {
|
|
162
|
+
cancelled = true
|
|
163
|
+
loader.cleanup()
|
|
164
|
+
}
|
|
165
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
166
|
+
}, [fontUrl])
|
|
167
|
+
|
|
168
|
+
// Imperative glyph text on select/hover.
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (glyphRef.current) glyphRef.current.textContent = displayGlyph
|
|
171
|
+
}, [displayGlyph])
|
|
172
|
+
|
|
173
|
+
// Metric-line overlay — overlay-relative coords, KOL tokens, mono type var.
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!fontData || !metrics || !glyphRef.current || !overlayRef.current) return
|
|
176
|
+
const glyph = glyphRef.current
|
|
177
|
+
const overlay = overlayRef.current
|
|
178
|
+
|
|
179
|
+
const render = () => {
|
|
180
|
+
const overlayRect = overlay.getBoundingClientRect()
|
|
181
|
+
const glyphRect = glyph.getBoundingClientRect()
|
|
182
|
+
const glyphTop = glyphRect.top - overlayRect.top
|
|
183
|
+
|
|
184
|
+
const fontSize = parseFloat(window.getComputedStyle(glyph).fontSize)
|
|
185
|
+
if (!fontSize || Number.isNaN(fontSize)) return
|
|
186
|
+
|
|
187
|
+
const scale = fontSize / metrics.unitsPerEm
|
|
188
|
+
const totalPixelHeight = (metrics.ascender - metrics.descender) * scale
|
|
189
|
+
const baseline =
|
|
190
|
+
glyphTop + glyphRect.height / 2 - totalPixelHeight / 2 + metrics.ascender * scale
|
|
191
|
+
|
|
192
|
+
const lines = [
|
|
193
|
+
{ y: baseline - metrics.capHeight * scale, label: 'Cap Height', value: metrics.capHeight },
|
|
194
|
+
{ y: baseline - metrics.ascender * scale, label: 'Ascender', value: metrics.ascender },
|
|
195
|
+
{ y: baseline - metrics.xHeight * scale, label: 'x-height', value: metrics.xHeight },
|
|
196
|
+
{ y: baseline, label: 'Baseline', value: 0 },
|
|
197
|
+
{ y: baseline - metrics.descender * scale, label: 'Descender', value: metrics.descender },
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
overlay.innerHTML = ''
|
|
201
|
+
const labelCss = (side, y) => `
|
|
202
|
+
position: absolute; ${side}: 13px; top: ${y - 18}px;
|
|
203
|
+
opacity: 0.8; color: var(--kol-surface-on-primary);
|
|
204
|
+
font-size: 12px; font-family: var(--kol-font-family-mono, monospace);
|
|
205
|
+
line-height: 12px; user-select: none;`
|
|
206
|
+
lines.forEach(({ y, label, value }) => {
|
|
207
|
+
if (!Number.isFinite(y)) return
|
|
208
|
+
const line = document.createElement('div')
|
|
209
|
+
line.style.cssText = `position: absolute; left: 0; right: 0; top: ${y}px; border-top: 1px solid var(--kol-border-default);`
|
|
210
|
+
overlay.appendChild(line)
|
|
211
|
+
const left = document.createElement('div')
|
|
212
|
+
left.style.cssText = labelCss('left', y)
|
|
213
|
+
left.textContent = label
|
|
214
|
+
overlay.appendChild(left)
|
|
215
|
+
const right = document.createElement('div')
|
|
216
|
+
right.style.cssText = labelCss('right', y)
|
|
217
|
+
right.textContent = value
|
|
218
|
+
overlay.appendChild(right)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const raf = requestAnimationFrame(render)
|
|
223
|
+
return () => cancelAnimationFrame(raf)
|
|
224
|
+
}, [fontData, metrics, displayGlyph])
|
|
225
|
+
|
|
226
|
+
const charCode = displayGlyph.charCodeAt(0)
|
|
227
|
+
const unicodeHex = charCode.toString(16).toUpperCase().padStart(4, '0')
|
|
228
|
+
|
|
229
|
+
const cellStyle = {
|
|
230
|
+
fontFamily: renderFamily,
|
|
231
|
+
fontStyle,
|
|
232
|
+
fontVariationSettings: fontVariationSettingsCSS,
|
|
233
|
+
outline: '1px solid var(--kol-border-default)',
|
|
234
|
+
outlineOffset: '-0.5px',
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const renderGrid = (glyphs, title) => (
|
|
238
|
+
<div className="w-full flex flex-col gap-4">
|
|
239
|
+
<div className="text-auto text-base md:text-lg kol-mono-text leading-7">{title}</div>
|
|
240
|
+
<div
|
|
241
|
+
className="inline-flex justify-start items-start flex-wrap"
|
|
242
|
+
onMouseLeave={() => setHoveredGlyph(null)}
|
|
243
|
+
>
|
|
244
|
+
{glyphs.map((glyph, index) => {
|
|
245
|
+
const isSelected = glyph === selectedGlyph
|
|
246
|
+
return (
|
|
247
|
+
<div
|
|
248
|
+
key={index}
|
|
249
|
+
onClick={() => setSelectedGlyph(glyph)}
|
|
250
|
+
onMouseEnter={() => setHoveredGlyph(glyph)}
|
|
251
|
+
className={`w-12 h-12 md:w-14 md:h-14 lg:w-16 lg:h-16 inline-flex flex-col justify-center items-center overflow-hidden cursor-pointer transition-colors duration-150 text-center text-lg md:text-xl lg:text-2xl leading-6 ${
|
|
252
|
+
isSelected ? 'bg-surface-inverse' : 'bg-transparent text-auto hover:bg-fg-08'
|
|
253
|
+
}`}
|
|
254
|
+
style={cellStyle}
|
|
255
|
+
>
|
|
256
|
+
{glyph}
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
})}
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<div className="bg-surface-primary flex flex-col lg:flex-row justify-start items-start gap-6 md:gap-8 lg:gap-10">
|
|
266
|
+
{/* Left: glyph viewer + metrics overlay */}
|
|
267
|
+
<div className="w-full lg:flex-[504] flex flex-col justify-start items-start gap-4 md:gap-6">
|
|
268
|
+
<div className="text-auto text-base md:text-lg kol-mono-text leading-7">Glyph Viewer</div>
|
|
269
|
+
|
|
270
|
+
<div className="w-full flex flex-col justify-start items-start gap-4 md:gap-6 lg:gap-10">
|
|
271
|
+
<div className="self-stretch h-64 md:h-80 lg:h-96 relative rounded-md overflow-hidden">
|
|
272
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
273
|
+
<span
|
|
274
|
+
ref={glyphRef}
|
|
275
|
+
className="text-center text-auto"
|
|
276
|
+
style={{
|
|
277
|
+
fontSize: 'clamp(180px, 25vw, 316px)',
|
|
278
|
+
lineHeight: '1',
|
|
279
|
+
fontFamily: renderFamily,
|
|
280
|
+
fontStyle,
|
|
281
|
+
fontVariationSettings: fontVariationSettingsCSS,
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
{displayGlyph}
|
|
285
|
+
</span>
|
|
286
|
+
</div>
|
|
287
|
+
<div ref={overlayRef} className="absolute inset-0 pointer-events-none" aria-hidden />
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{/* Metadata */}
|
|
291
|
+
<div className="hidden md:inline-flex justify-start items-start gap-8">
|
|
292
|
+
<div className="opacity-80 text-auto text-sm md:text-base lg:text-lg kol-mono-text leading-7">
|
|
293
|
+
Font style<br />
|
|
294
|
+
Glyph name<br />
|
|
295
|
+
Unicode<br />
|
|
296
|
+
Decimal<br />
|
|
297
|
+
Hex
|
|
298
|
+
</div>
|
|
299
|
+
<div className="opacity-80 text-auto text-sm md:text-base lg:text-lg kol-mono-text leading-7">
|
|
300
|
+
{fontStyle === 'italic' ? 'Italic' : 'Roman'}<br />
|
|
301
|
+
{displayGlyph}<br />
|
|
302
|
+
U+{unicodeHex}<br />
|
|
303
|
+
{charCode}<br />
|
|
304
|
+
0x{unicodeHex}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
{/* Right: dual grids */}
|
|
311
|
+
<div className="w-full lg:flex-[832] flex flex-col justify-start items-start gap-4 md:gap-6">
|
|
312
|
+
<div className="flex lg:hidden gap-3">
|
|
313
|
+
<Tag hash={false} active={activeTab === 'uppercase'} onClick={() => setActiveTab('uppercase')}>
|
|
314
|
+
Uppercase & Latin
|
|
315
|
+
</Tag>
|
|
316
|
+
<Tag hash={false} active={activeTab === 'lowercase'} onClick={() => setActiveTab('lowercase')}>
|
|
317
|
+
Lowercase & Extended
|
|
318
|
+
</Tag>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<div className="lg:hidden w-full">
|
|
322
|
+
{activeTab === 'uppercase' && renderGrid(uppercaseGlyphs, 'Uppercase & Latin')}
|
|
323
|
+
{activeTab === 'lowercase' && renderGrid(lowercaseGlyphs, 'Lowercase & Extended')}
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<div className="hidden lg:flex lg:flex-col lg:gap-6 w-full">
|
|
327
|
+
{renderGrid(uppercaseGlyphs, 'Uppercase & Latin')}
|
|
328
|
+
{renderGrid(lowercaseGlyphs, 'Lowercase & Extended')}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default GlyphMetricsGrid
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
+
import { Dropdown } from '@kolkrabbi/kol-component'
|
|
3
|
+
import { Divider } from '@kolkrabbi/kol-component'
|
|
4
|
+
|
|
5
|
+
/* taxonomy-ok: nests DS Dropdown + Divider (relative imports) plus kol-icons's
|
|
6
|
+
* Icon (a package import the relative-import check can't see). */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SpecimenSectionHeader — the shared header every type-specimen section mounts
|
|
10
|
+
* under. A title (with an optional inline icon) on the left, up to two
|
|
11
|
+
* controlled dropdowns (weight + style/axis) on the right, and a horizontal
|
|
12
|
+
* divider beneath. It is the connective tissue that makes the foundry sections
|
|
13
|
+
* read as one system, so it is built before the sections that consume it.
|
|
14
|
+
*
|
|
15
|
+
* Fully controlled and data-free: it holds no font data, only label/badge text,
|
|
16
|
+
* an icon name, and the two dropdowns' value/onChange pairs supplied by the
|
|
17
|
+
* parent section. Promoted from the monorepo's route-local `FoundrySection`;
|
|
18
|
+
* the `Foundry*` name was a route artifact, this name describes the role.
|
|
19
|
+
*
|
|
20
|
+
* Title source forks: `label` wins, else `badgeText`. `size` swaps the title
|
|
21
|
+
* type stop (`sm` → kol-mono-12, else kol-mono-16). Each dropdown is
|
|
22
|
+
* independently gated; the weight dropdown also requires a non-empty
|
|
23
|
+
* `weightOptions`.
|
|
24
|
+
*
|
|
25
|
+
* Text casing: title and option labels render verbatim as authored — no
|
|
26
|
+
* text-transform. Author "Roman" / "Italic" / "Character Set" in intended case.
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} props
|
|
29
|
+
* @param {string} props.label - Title text (takes precedence over badgeText).
|
|
30
|
+
* @param {string} props.badgeText - Fallback title when label is unset.
|
|
31
|
+
* @param {string} props.icon - KOL Icon name shown after the title; omit to hide.
|
|
32
|
+
* @param {'sm'|'lg'} props.size - Title type stop (default 'lg').
|
|
33
|
+
* @param {string} props.selectedStyle - Controlled value of the style/axis dropdown.
|
|
34
|
+
* @param {Function} props.onStyleChange - Style dropdown change handler.
|
|
35
|
+
* @param {boolean} props.showDropdown - Render the style/axis dropdown (default true).
|
|
36
|
+
* @param {Array<{label,value}>} props.styleOptions - Style dropdown options (default Roman/Italic).
|
|
37
|
+
* @param {string} props.selectedWeight - Controlled value of the weight dropdown.
|
|
38
|
+
* @param {Function} props.onWeightChange - Weight dropdown change handler.
|
|
39
|
+
* @param {boolean} props.showWeightDropdown - Gate the weight dropdown (default true; also needs options).
|
|
40
|
+
* @param {Array<{label,value}>} props.weightOptions - Weight dropdown options (default []).
|
|
41
|
+
*/
|
|
42
|
+
const SpecimenSectionHeader = ({
|
|
43
|
+
label,
|
|
44
|
+
badgeText,
|
|
45
|
+
icon,
|
|
46
|
+
size = 'lg',
|
|
47
|
+
selectedStyle,
|
|
48
|
+
onStyleChange,
|
|
49
|
+
showDropdown = true,
|
|
50
|
+
styleOptions = [
|
|
51
|
+
{ label: 'Roman', value: 'roman' },
|
|
52
|
+
{ label: 'Italic', value: 'italic' },
|
|
53
|
+
],
|
|
54
|
+
selectedWeight,
|
|
55
|
+
onWeightChange,
|
|
56
|
+
showWeightDropdown = true,
|
|
57
|
+
weightOptions = [],
|
|
58
|
+
}) => {
|
|
59
|
+
const title = label || badgeText
|
|
60
|
+
// kol-mono-text-lg / kol-mono-sm-regular don't exist in the DS theme; the
|
|
61
|
+
// nearest mono stops are kol-mono-16 (lg) and kol-mono-12 (sm).
|
|
62
|
+
const titleClass = size === 'sm' ? 'kol-mono-12' : 'kol-mono-16'
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex flex-col gap-[13px]">
|
|
66
|
+
<div className="w-full flex flex-row justify-between items-end gap-4">
|
|
67
|
+
{/* Left: title + optional icon */}
|
|
68
|
+
<div className="flex items-center gap-3 md:gap-4">
|
|
69
|
+
<span className={titleClass}>{title}</span>
|
|
70
|
+
{icon && <Icon name={icon} size={20} />}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Right: controlled dropdowns */}
|
|
74
|
+
<div className="flex items-center gap-4">
|
|
75
|
+
{showWeightDropdown && weightOptions.length > 0 && (
|
|
76
|
+
<Dropdown options={weightOptions} value={selectedWeight} onChange={onWeightChange} />
|
|
77
|
+
)}
|
|
78
|
+
{showDropdown && (
|
|
79
|
+
<Dropdown options={styleOptions} value={selectedStyle} onChange={onStyleChange} />
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<Divider variant="horizontal" />
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default SpecimenSectionHeader
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Pill } from '@kolkrabbi/kol-component'
|
|
2
|
+
import { Button } from '@kolkrabbi/kol-component'
|
|
3
|
+
|
|
4
|
+
/* taxonomy-ok: organism — nests Pill + Button (relative imports); a full
|
|
5
|
+
* page-hero region rendering live in the specimen's own font-family. */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TypefaceHero — the specimen hero for a typeface page: a centered stack of
|
|
9
|
+
* category pill → giant display name rendered in the typeface's OWN fontFamily
|
|
10
|
+
* → in-family description → download / view-specimen CTAs → an optional
|
|
11
|
+
* licensing caption. Data-driven from one `typeface` object; the giant name is
|
|
12
|
+
* the live font-family preview (arbitrary loaded font, inline `fontFamily`, not
|
|
13
|
+
* a fixed KOL type class — only the size stops are Tailwind).
|
|
14
|
+
*
|
|
15
|
+
* Router-decoupled: the monorepo source called `useNavigate(specimenLink)`;
|
|
16
|
+
* here the "View Specimen" CTA takes an `href` and/or `onSpecimenClick` callback
|
|
17
|
+
* so the DS never depends on a router. CTA copy + the license note are
|
|
18
|
+
* prop-driven (license default off), and `displayName` falls back to
|
|
19
|
+
* `id`/`fontFamily` when the config omits it.
|
|
20
|
+
*
|
|
21
|
+
* Text casing: displayName, category, description and CTA/license strings render
|
|
22
|
+
* verbatim as authored — no text-transform.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} props
|
|
25
|
+
* @param {Object} props.typeface - Config: { displayName?, id?, fontFamily, fontStyle?, category?, description?, specimenLink? }.
|
|
26
|
+
* @param {string} props.downloadHref - Optional href for the "Download font" CTA.
|
|
27
|
+
* @param {string} props.downloadLabel - Download CTA label (default 'Download font').
|
|
28
|
+
* @param {string} props.specimenLabel - View-specimen CTA label (default 'View Specimen').
|
|
29
|
+
* @param {Function} props.onSpecimenClick - Handler for the view-specimen CTA (receives the event).
|
|
30
|
+
* @param {string} props.licenseNote - Licensing caption; omit to hide.
|
|
31
|
+
*/
|
|
32
|
+
const TypefaceHero = ({
|
|
33
|
+
typeface = {},
|
|
34
|
+
downloadHref,
|
|
35
|
+
downloadLabel = 'Download font',
|
|
36
|
+
specimenLabel = 'View Specimen',
|
|
37
|
+
onSpecimenClick,
|
|
38
|
+
licenseNote,
|
|
39
|
+
}) => {
|
|
40
|
+
const { displayName, id, fontFamily, fontStyle, category, description, specimenLink } = typeface
|
|
41
|
+
const name = displayName || id || fontFamily
|
|
42
|
+
const isItalic = fontStyle === 'italic'
|
|
43
|
+
|
|
44
|
+
const handleSpecimenClick = (e) => {
|
|
45
|
+
if (onSpecimenClick) {
|
|
46
|
+
e.preventDefault()
|
|
47
|
+
onSpecimenClick(e)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section className="py-48 md:py-72 flex flex-col justify-center text-center items-center overflow-hidden">
|
|
53
|
+
<div className="flex flex-col items-center gap-2 max-w-[1400px]">
|
|
54
|
+
{category && (
|
|
55
|
+
<div className="pb-5 flex flex-col items-center gap-2">
|
|
56
|
+
<Pill variant="subtle">{category}</Pill>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
<div className="pb-16 flex flex-col items-center gap-0">
|
|
61
|
+
<h1
|
|
62
|
+
className={`text-[64px] leading-[100%] md:text-[128px] font-semibold ${
|
|
63
|
+
isItalic ? 'italic' : ''
|
|
64
|
+
} text-auto transition-colors duration-300`}
|
|
65
|
+
style={{ fontFamily }}
|
|
66
|
+
>
|
|
67
|
+
{name}
|
|
68
|
+
</h1>
|
|
69
|
+
|
|
70
|
+
{description && (
|
|
71
|
+
<p
|
|
72
|
+
className={`text-xl font-semibold ${
|
|
73
|
+
isItalic ? 'italic' : ''
|
|
74
|
+
} text-auto transition-colors duration-300`}
|
|
75
|
+
style={{ fontFamily }}
|
|
76
|
+
>
|
|
77
|
+
{description}
|
|
78
|
+
</p>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="flex flex-col items-center gap-2">
|
|
83
|
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
84
|
+
<Button variant="primary" href={downloadHref}>
|
|
85
|
+
{downloadLabel}
|
|
86
|
+
</Button>
|
|
87
|
+
<Button
|
|
88
|
+
variant="outline"
|
|
89
|
+
href={specimenLink}
|
|
90
|
+
onClick={onSpecimenClick ? handleSpecimenClick : undefined}
|
|
91
|
+
>
|
|
92
|
+
{specimenLabel}
|
|
93
|
+
</Button>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{licenseNote && (
|
|
97
|
+
<p className="kol-mono-12 text-auto pt-4 transition-colors duration-300" style={{ opacity: 0.64 }}>
|
|
98
|
+
{licenseNote}
|
|
99
|
+
</p>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default TypefaceHero
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import SpecimenSectionHeader from './SpecimenSectionHeader.jsx'
|
|
3
|
+
|
|
4
|
+
/* taxonomy-ok: organism — nests SpecimenSectionHeader (relative import) plus a
|
|
5
|
+
* same-file StyleCard row; owns the axis/selection state. */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* StyleCard (same-file) — one style row: the label rendered live in its own
|
|
9
|
+
* weight/width/italic on the left, the numeric value on the right, with an
|
|
10
|
+
* active/hover flip. Inlined (the monorepo's `.style-card` CSS classes aren't
|
|
11
|
+
* in the DS theme) and rebuilt Tailwind-first.
|
|
12
|
+
*/
|
|
13
|
+
function StyleCard({ label, weight, width, italic, isActive, onHover, onClick, fontFamily }) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
onMouseEnter={onHover}
|
|
17
|
+
onClick={onClick}
|
|
18
|
+
className={`flex items-center justify-between gap-4 px-4 py-3 rounded cursor-pointer border transition-colors duration-150 ${
|
|
19
|
+
isActive ? 'bg-surface-inverse' : 'border-transparent hover:bg-fg-08'
|
|
20
|
+
}`}
|
|
21
|
+
style={{ borderColor: isActive ? 'transparent' : undefined }}
|
|
22
|
+
>
|
|
23
|
+
<span
|
|
24
|
+
className="text-2xl md:text-3xl leading-none truncate"
|
|
25
|
+
style={{
|
|
26
|
+
fontFamily,
|
|
27
|
+
fontStyle: italic ? 'italic' : 'normal',
|
|
28
|
+
fontWeight: weight || 400,
|
|
29
|
+
fontVariationSettings: width ? `'wdth' ${width}` : undefined,
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
{label}
|
|
33
|
+
</span>
|
|
34
|
+
<span className="kol-mono-12 shrink-0 opacity-70">{width || weight}</span>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pickDefault = (list) => list.find((s) => s.isDefault) || list[3] || list[0]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* TypefaceStyleSection — the weight/width/italic style showcase: a sticky
|
|
43
|
+
* inverted preview panel on the left (renders `AaBbCc / 01234567 / {(!@#$?&)}`
|
|
44
|
+
* in the hovered/selected style) beside a grid of every available style on the
|
|
45
|
+
* right. Derives its behavior generically from `typeface.styles`: italic →
|
|
46
|
+
* Roman/Italic dropdown; weight+width → Weight/Width axis dropdown; single-axis
|
|
47
|
+
* / static → no dropdown.
|
|
48
|
+
*
|
|
49
|
+
* Default selection prefers a style flagged `isDefault`, falling back to index
|
|
50
|
+
* (Regular ≈ [3]) — the monorepo's magic indices assumed a fixed ordering; the
|
|
51
|
+
* flag survives reordering. All preview typography stays inline from the
|
|
52
|
+
* selected style (fontFamily / fontWeight / fontStyle / wdth variation) because
|
|
53
|
+
* arbitrary loaded fonts + variable axes can't be fixed KOL type classes.
|
|
54
|
+
*
|
|
55
|
+
* Text casing: badgeText, style labels and specimen strings render verbatim.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} props.typeface - { fontFamily, badgeText, styles:{ hasWeight, hasWidth, hasItalic, defaultStyle?, weights[], widths[] } }.
|
|
58
|
+
* @param {string[]} props.sampleLines - Preview lines (default AaBbCc / 01234567 / {(!@#$?&)}).
|
|
59
|
+
*/
|
|
60
|
+
const TypefaceStyleSection = ({
|
|
61
|
+
typeface = {},
|
|
62
|
+
sampleLines = ['AaBbCc', '01234567', '{(!@#$?&)}'],
|
|
63
|
+
}) => {
|
|
64
|
+
const { fontFamily, badgeText, styles: styleConfig = {} } = typeface
|
|
65
|
+
const {
|
|
66
|
+
hasWeight,
|
|
67
|
+
hasWidth,
|
|
68
|
+
hasItalic,
|
|
69
|
+
defaultStyle = 'weight',
|
|
70
|
+
weights = [],
|
|
71
|
+
widths = [],
|
|
72
|
+
} = styleConfig
|
|
73
|
+
|
|
74
|
+
const showDropdown = hasItalic || (hasWeight && hasWidth)
|
|
75
|
+
const styleOptions = hasItalic
|
|
76
|
+
? [
|
|
77
|
+
{ label: 'Roman', value: 'roman' },
|
|
78
|
+
{ label: 'Italic', value: 'italic' },
|
|
79
|
+
]
|
|
80
|
+
: hasWeight && hasWidth
|
|
81
|
+
? [
|
|
82
|
+
{ label: 'Weight', value: 'weight' },
|
|
83
|
+
{ label: 'Width', value: 'width' },
|
|
84
|
+
]
|
|
85
|
+
: null
|
|
86
|
+
|
|
87
|
+
const [selectedStyleVariant, setSelectedStyleVariant] = useState(
|
|
88
|
+
hasItalic ? 'italic' : defaultStyle,
|
|
89
|
+
)
|
|
90
|
+
const isItalic = selectedStyleVariant === 'italic'
|
|
91
|
+
const activeList = showDropdown && selectedStyleVariant === 'width' ? widths : weights
|
|
92
|
+
|
|
93
|
+
const [currentStyle, setCurrentStyle] = useState(() => pickDefault(activeList) || {})
|
|
94
|
+
|
|
95
|
+
const handleStyleVariantChange = (newVariant) => {
|
|
96
|
+
setSelectedStyleVariant(newVariant)
|
|
97
|
+
if (newVariant === 'width') setCurrentStyle(pickDefault(widths) || {})
|
|
98
|
+
else setCurrentStyle(pickDefault(weights) || {})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const previewStyle = {
|
|
102
|
+
fontFamily,
|
|
103
|
+
fontWeight: currentStyle.weight || 400,
|
|
104
|
+
fontStyle: isItalic ? 'italic' : 'normal',
|
|
105
|
+
...(currentStyle.width ? { fontVariationSettings: `'wdth' ${currentStyle.width}` } : {}),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<section className="w-full py-12 lg:py-16">
|
|
110
|
+
<div className="max-w-[1400px] mx-auto flex flex-col gap-8">
|
|
111
|
+
<SpecimenSectionHeader
|
|
112
|
+
selectedStyle={selectedStyleVariant}
|
|
113
|
+
onStyleChange={handleStyleVariantChange}
|
|
114
|
+
styleOptions={styleOptions || undefined}
|
|
115
|
+
showDropdown={showDropdown}
|
|
116
|
+
badgeText={badgeText}
|
|
117
|
+
icon="foundation"
|
|
118
|
+
size="sm"
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
<div className="flex flex-row gap-4 md:gap-6 lg:gap-8 items-start w-full">
|
|
122
|
+
{/* Left: sticky inverted preview panel */}
|
|
123
|
+
<div className="w-1/2 aspect-[4/3] p-6 md:p-12 transition-colors duration-300 sticky top-24 bg-surface-inverse rounded">
|
|
124
|
+
<div
|
|
125
|
+
className="text-center transition-colors duration-300 w-full h-full flex flex-col justify-center items-center gap-2"
|
|
126
|
+
style={previewStyle}
|
|
127
|
+
>
|
|
128
|
+
{sampleLines.map((line, i) => (
|
|
129
|
+
<div key={i} className="text-3xl md:text-5xl lg:text-6xl leading-none">
|
|
130
|
+
{line}
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Right: styles list */}
|
|
137
|
+
<div className="w-1/2 flex flex-col gap-3">
|
|
138
|
+
{activeList.map((style, index) => (
|
|
139
|
+
<StyleCard
|
|
140
|
+
key={`${style.label}-${index}`}
|
|
141
|
+
label={style.label}
|
|
142
|
+
weight={style.weight}
|
|
143
|
+
width={style.width}
|
|
144
|
+
italic={isItalic}
|
|
145
|
+
isActive={
|
|
146
|
+
currentStyle?.label === style.label &&
|
|
147
|
+
(style.weight
|
|
148
|
+
? currentStyle?.weight === style.weight
|
|
149
|
+
: currentStyle?.width === style.width)
|
|
150
|
+
}
|
|
151
|
+
onHover={() => setCurrentStyle(style)}
|
|
152
|
+
onClick={() => setCurrentStyle(style)}
|
|
153
|
+
fontFamily={fontFamily}
|
|
154
|
+
/>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</section>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default TypefaceStyleSection
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Pill } from '@kolkrabbi/kol-component'
|
|
3
|
+
import { Slider } from '@kolkrabbi/kol-component'
|
|
4
|
+
import SpecimenSectionHeader from './SpecimenSectionHeader.jsx'
|
|
5
|
+
import { useAxisAnimation } from '@kolkrabbi/kol-component'
|
|
6
|
+
|
|
7
|
+
/* taxonomy-ok: organism — nests Pill, Slider, SpecimenSectionHeader (relative
|
|
8
|
+
* imports) plus a same-file PlayPauseButton; owns the axis-animation state. */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* PlayPauseButton (same-file) — CSS-shape play/pause toggle. Two bars when
|
|
12
|
+
* playing, a right-pointing triangle when paused. Pure `currentColor` shapes so
|
|
13
|
+
* it needs no icon registry and inherits the surrounding text color.
|
|
14
|
+
*/
|
|
15
|
+
function PlayPauseButton({ isPlaying = false, onToggle, size = 28 }) {
|
|
16
|
+
const barH = Math.round(size * 0.43)
|
|
17
|
+
const barW = Math.max(2, Math.round(size * 0.07))
|
|
18
|
+
const tri = Math.round(size * 0.21)
|
|
19
|
+
const gap = Math.max(2, Math.round(size * 0.07))
|
|
20
|
+
return (
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
onClick={onToggle}
|
|
24
|
+
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
25
|
+
aria-pressed={isPlaying}
|
|
26
|
+
className="cursor-pointer flex items-center justify-center text-auto shrink-0"
|
|
27
|
+
style={{ width: size, height: size, gap }}
|
|
28
|
+
>
|
|
29
|
+
{isPlaying ? (
|
|
30
|
+
<>
|
|
31
|
+
<span className="bg-current" style={{ width: barW, height: barH }} />
|
|
32
|
+
<span className="bg-current" style={{ width: barW, height: barH }} />
|
|
33
|
+
</>
|
|
34
|
+
) : (
|
|
35
|
+
<span
|
|
36
|
+
className="w-0 h-0"
|
|
37
|
+
style={{
|
|
38
|
+
borderLeft: `${tri}px solid currentColor`,
|
|
39
|
+
borderTop: `${tri}px solid transparent`,
|
|
40
|
+
borderBottom: `${tri}px solid transparent`,
|
|
41
|
+
marginLeft: Math.round(size * 0.07),
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
</button>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* VariableFontSection — the animated variable-axis playground. A big specimen
|
|
51
|
+
* word whose weight auto-oscillates between `minWeight` and `maxWeight` via
|
|
52
|
+
* `useAxisAnimation` (ping-pong rAF, reduced-motion gated), pausing the instant
|
|
53
|
+
* the user scrubs the weight slider. A SpecimenSectionHeader sits above with a
|
|
54
|
+
* Roman/Italic dropdown. "Watch the weight breathe, then grab the slider."
|
|
55
|
+
*
|
|
56
|
+
* The weight is applied as CSS `font-weight` (→ the font's wght axis), so it
|
|
57
|
+
* animates against ANY font with multiple weights — a true variable font makes
|
|
58
|
+
* the morph continuous rather than stepped. See the page's FONT-ASSET CONTRACT.
|
|
59
|
+
*
|
|
60
|
+
* Reconciliations vs the monorepo source: the crude `Date.now()`/`delta>16`
|
|
61
|
+
* throttle is replaced by the hook's `performance.now()` accumulator; the
|
|
62
|
+
* previously unguarded loop now honors `prefers-reduced-motion` (starts paused,
|
|
63
|
+
* static value); brand copy defaults ('Málrómur Aa' / 'Variable' / TGMalromur)
|
|
64
|
+
* are dropped for neutral defaults.
|
|
65
|
+
*
|
|
66
|
+
* Text casing: badgeText, text and dropdown labels render verbatim.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} props
|
|
69
|
+
* @param {string} props.fontFamily - Specimen family (default system stack).
|
|
70
|
+
* @param {string} props.badgeText - Header title (default 'Variable axis').
|
|
71
|
+
* @param {string} props.text - The big specimen word (default 'Variable').
|
|
72
|
+
* @param {number} props.minWeight - Oscillation + slider lower bound (default 300).
|
|
73
|
+
* @param {number} props.maxWeight - Oscillation + slider upper bound (default 900).
|
|
74
|
+
* @param {boolean} props.showDropdown - Show the Roman/Italic dropdown (default true).
|
|
75
|
+
*/
|
|
76
|
+
const VariableFontSection = ({
|
|
77
|
+
fontFamily = 'system-ui, sans-serif',
|
|
78
|
+
badgeText = 'Variable axis',
|
|
79
|
+
text = 'Variable',
|
|
80
|
+
minWeight = 300,
|
|
81
|
+
maxWeight = 900,
|
|
82
|
+
showDropdown = true,
|
|
83
|
+
}) => {
|
|
84
|
+
const [isAnimating, setIsAnimating] = useState(true)
|
|
85
|
+
const [selectedStyle, setSelectedStyle] = useState(showDropdown ? 'italic' : 'roman')
|
|
86
|
+
|
|
87
|
+
const { value: weight, setValue: setWeight, reduced } = useAxisAnimation({
|
|
88
|
+
min: minWeight,
|
|
89
|
+
max: maxWeight,
|
|
90
|
+
running: isAnimating,
|
|
91
|
+
initial: 400,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const handleSliderChange = (value) => {
|
|
95
|
+
setIsAnimating(false)
|
|
96
|
+
setWeight(value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const playing = isAnimating && !reduced
|
|
100
|
+
const fontStyle = selectedStyle === 'italic' ? 'italic' : 'normal'
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<section className="w-full py-12 lg:py-16">
|
|
104
|
+
<div className="max-w-[1400px] mx-auto flex flex-col gap-8">
|
|
105
|
+
<SpecimenSectionHeader
|
|
106
|
+
selectedStyle={selectedStyle}
|
|
107
|
+
onStyleChange={setSelectedStyle}
|
|
108
|
+
showDropdown={showDropdown}
|
|
109
|
+
badgeText={badgeText}
|
|
110
|
+
icon="foundation"
|
|
111
|
+
size="sm"
|
|
112
|
+
/>
|
|
113
|
+
|
|
114
|
+
{/* VariableFontDisplay (inlined) — giant text behind, controls in front */}
|
|
115
|
+
<div className="relative w-full rounded border border-fg-16 bg-surface-primary overflow-hidden p-6 md:p-10 h-[40vh] md:h-[60vh]">
|
|
116
|
+
<p
|
|
117
|
+
className="absolute inset-0 flex items-center justify-center text-[80px] md:text-[144px] transition-colors duration-300"
|
|
118
|
+
style={{
|
|
119
|
+
fontFamily,
|
|
120
|
+
fontWeight: Math.round(weight),
|
|
121
|
+
fontStyle,
|
|
122
|
+
color: 'var(--kol-surface-on-primary)',
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{text}
|
|
126
|
+
</p>
|
|
127
|
+
|
|
128
|
+
<div className="relative z-10 h-full flex flex-col justify-between">
|
|
129
|
+
<div className="flex justify-between items-start">
|
|
130
|
+
<div className="flex flex-col gap-1">
|
|
131
|
+
<span className="kol-mono-10 text-fg-32">AXES:</span>
|
|
132
|
+
<span className="kol-helper-12 text-auto">WEIGHT</span>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="flex gap-2">
|
|
135
|
+
<Pill variant="subtle">wght {Math.round(weight)}</Pill>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className="flex items-center gap-4">
|
|
140
|
+
<PlayPauseButton isPlaying={playing} onToggle={() => setIsAnimating((a) => !a)} />
|
|
141
|
+
<Slider
|
|
142
|
+
label="Weight"
|
|
143
|
+
min={minWeight}
|
|
144
|
+
max={maxWeight}
|
|
145
|
+
value={Math.round(weight)}
|
|
146
|
+
onChange={handleSliderChange}
|
|
147
|
+
variant="minimal"
|
|
148
|
+
className="w-full"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default VariableFontSection
|
package/src/glyphData.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foundry glyph data — static character collections + category metadata for the
|
|
3
|
+
* type-specimen sections (GlyphMetricsGrid, FoundryCharacterSets). Ported from
|
|
4
|
+
* the monorepo `@kol/ui/data` glyphSets so the DS foundry layer carries its own
|
|
5
|
+
* default coverage instead of reaching into an app data package. Consumers can
|
|
6
|
+
* override any of it via the `uppercaseGlyphs` / `lowercaseGlyphs` / category
|
|
7
|
+
* props.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const glyphSets = {
|
|
11
|
+
uppercase: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'],
|
|
12
|
+
lowercase: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'],
|
|
13
|
+
numbers: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
|
|
14
|
+
punctuation: ['.', ',', '!', '?', ';', ':', '"', "'", '-', '—', '(', ')', '[', ']', '{', '}', '&', '@', '#', '$', '%'],
|
|
15
|
+
latin1: ['À', 'Á', 'Â', 'Ã', 'Ä', 'Å', 'Æ', 'Ç', 'È', 'É', 'Ê', 'Ë', 'Ì', 'Í', 'Î', 'Ï', 'Ð', 'Ñ', 'Ò', 'Ó', 'Ô', 'Õ', 'Ö', 'Ø'],
|
|
16
|
+
latinExtended: ['Ā', 'Ă', 'Ą', 'Ć', 'Ĉ', 'Ċ', 'Č', 'Ď', 'Đ', 'Ē', 'Ĕ', 'Ė', 'Ę', 'Ě', 'Ĝ', 'Ğ', 'Ġ', 'Ģ', 'Ĥ', 'Ħ', 'Ĩ', 'Ī', 'Ĭ', 'Į'],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const glyphCategories = [
|
|
20
|
+
{ key: 'uppercase', title: 'Uppercase' },
|
|
21
|
+
{ key: 'lowercase', title: 'Lowercase' },
|
|
22
|
+
{ key: 'numbers', title: 'Numbers' },
|
|
23
|
+
{ key: 'punctuation', title: 'Punctuation & Symbols' },
|
|
24
|
+
{ key: 'latin1', title: 'Latin-1 Supported' },
|
|
25
|
+
{ key: 'latinExtended', title: 'Latin Extended' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** Default preview pangram — neutral English, authored in intended case. */
|
|
29
|
+
export const SPECIMEN_SAMPLE_TEXT =
|
|
30
|
+
'The quick brown fox jumps over the lazy dog while five wizards vex the gnomic judge.'
|
package/src/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// molecule (shared section header)
|
|
10
|
+
export { default as SpecimenSectionHeader } from './SpecimenSectionHeader.jsx'
|
|
11
|
+
|
|
12
|
+
// organisms (specimen sections)
|
|
13
|
+
export { default as TypefaceHero } from './TypefaceHero.jsx'
|
|
14
|
+
export { default as VariableFontSection } from './VariableFontSection.jsx'
|
|
15
|
+
export { default as GlyphMetricsGrid } from './GlyphMetricsGrid.jsx'
|
|
16
|
+
export { default as TypefaceStyleSection } from './TypefaceStyleSection.jsx'
|
|
17
|
+
export { default as FontPreviewSection } from './FontPreviewSection.jsx'
|
|
18
|
+
export { default as FoundryCharacterSets } from './FoundryCharacterSets.jsx'
|
|
19
|
+
|
|
20
|
+
// data
|
|
21
|
+
export { glyphSets, glyphCategories, SPECIMEN_SAMPLE_TEXT } from './glyphData.js'
|