@kolkrabbi/kol-foundry 0.3.0 → 0.4.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-foundry",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "description": "KOL foundry component set — the type-specimen apparatus: typeface hero, variable-font axis playground, parsed-metric glyph inspector, character-set browser, font-preview, and the typeface-catalog grid. Every component renders, inspects, or manipulates a live font. Typeface data is consumer-injected. Sits above @kolkrabbi/kol-{theme,icons,component}.",
6
6
  "license": "MIT",
@@ -11,9 +11,9 @@
11
11
  ".": "./src/index.js"
12
12
  },
13
13
  "dependencies": {
14
- "@kolkrabbi/kol-component": "0.8.0",
15
- "@kolkrabbi/kol-theme": "0.7.3",
16
- "@kolkrabbi/kol-icons": "0.5.0"
14
+ "@kolkrabbi/kol-component": "0.10.0",
15
+ "@kolkrabbi/kol-theme": "0.7.5",
16
+ "@kolkrabbi/kol-icons": "0.6.0"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^18.3.0 || ^19.0.0",
@@ -59,22 +59,20 @@ function FontPreviewItem({
59
59
  options={weightOptions}
60
60
  value={selectedWeight}
61
61
  onChange={setSelectedWeight}
62
- variant="minimal"
63
62
  />
64
63
  </div>
65
64
  <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" />
65
+ <Slider label="Size" min={12} max={200} value={size} onChange={setSize} className="flex-1" />
67
66
  <Slider
68
67
  label="Leading"
69
68
  min={0}
70
69
  max={50}
71
70
  value={leading}
72
71
  onChange={setLeading}
73
- variant="minimal"
74
72
  className="hidden md:flex flex-1"
75
73
  formatValue={(val) => 90 + val}
76
74
  />
77
- <Slider label="Spacing" min={-50} max={50} value={spacing} onChange={setSpacing} variant="minimal" className="hidden md:flex flex-1" />
75
+ <Slider label="Spacing" min={-50} max={50} value={spacing} onChange={setSpacing} className="hidden md:flex flex-1" />
78
76
  </div>
79
77
  </div>
80
78
 
@@ -0,0 +1,130 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react'
2
+
3
+ /* taxonomy-ok: organism — self-measuring live specimen; owns getComputedStyle
4
+ * I/O over rendered font samples. */
5
+
6
+ /**
7
+ * TypeSpecimenLive — a self-measuring type specimen. One row per type class,
8
+ * each rendering a live sample and reading its OWN `getComputedStyle` to print
9
+ * the resolved typeface / weight / size / leading / tracking beside it. Because
10
+ * the labels are measured off the real rendered element, they can never drift
11
+ * from the CSS: retune a token and the specimen follows.
12
+ *
13
+ * Ported from the kol portfolio-kit's `TypeSpecimen` page and generalised for
14
+ * the design system — the kit's hardcoded row list is now the consumer-injected
15
+ * `specs` prop, and the PP-Right-Grotesk / JetBrains-Mono weight-name maps are
16
+ * overridable props (defaulted to the kit's fonts). Casing is untouched: class
17
+ * names and samples render verbatim.
18
+ *
19
+ * WHY IT EXISTS: `TypeSpecCard` is a STATIC data sheet — the consumer types the
20
+ * metric tuples in by hand next to a sample. This component is its live cousin:
21
+ * it MEASURES the sample, so there's nothing to type and nothing to keep in
22
+ * sync. Richer, and honest by construction.
23
+ *
24
+ * @param {Object} props
25
+ * @param {Array<{className:string, sample:React.ReactNode, label?:string}>} props.specs
26
+ * The rows. `className` is the type class to render + measure; `sample` is the
27
+ * live copy; `label` overrides the printed class name (defaults to className).
28
+ * @param {string} [props.title] Optional header title (rendered verbatim).
29
+ * @param {string} [props.description] Optional header description (verbatim).
30
+ * @param {string} [props.sampleClassName] Extra class(es) on every sample element
31
+ * (default `text-emphasis` for a legible default color; pass '' to opt out).
32
+ * @param {Object<number,string>} [props.sansWeightNames] weight→name for
33
+ * proportional faces (default PP Right Grotesk ramp).
34
+ * @param {Object<number,string>} [props.monoWeightNames] weight→name for mono
35
+ * faces (default JetBrains Mono ramp).
36
+ * @param {{mono:string, sans:string}} [props.typefaceNames] Friendly typeface
37
+ * labels chosen by the mono/proportional split (default RG / JetBrains).
38
+ * @param {string} [props.className] Class(es) on the root <div>.
39
+ */
40
+
41
+ const DEFAULT_SANS_WEIGHTS = { 100: 'Fine', 300: 'Light', 400: 'Regular', 500: 'Medium', 600: 'Dark', 700: 'Bold', 900: 'Black' }
42
+ const DEFAULT_MONO_WEIGHTS = { 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold' }
43
+ const DEFAULT_TYPEFACE_NAMES = { mono: 'JetBrains Mono', sans: 'Right Grotesk' }
44
+
45
+ /**
46
+ * Read a computed-style object into a printable metric record. Exported so
47
+ * consumers can reuse the exact same derivation in tests or custom rows.
48
+ */
49
+ export function readMeta(cs, {
50
+ sansWeightNames = DEFAULT_SANS_WEIGHTS,
51
+ monoWeightNames = DEFAULT_MONO_WEIGHTS,
52
+ typefaceNames = DEFAULT_TYPEFACE_NAMES,
53
+ } = {}) {
54
+ const fam = cs.fontFamily.split(',')[0].replace(/["']/g, '').trim()
55
+ const mono = /mono/i.test(fam)
56
+ const typeface = mono ? typefaceNames.mono : typefaceNames.sans
57
+ const width = /Narrow/.test(fam) ? 'Narrow' : /Compact/.test(fam) ? 'Compact' : mono ? 'Monospace' : 'Regular'
58
+ const w = parseInt(cs.fontWeight, 10)
59
+ const wname = (mono ? monoWeightNames : sansWeightNames)[w]
60
+ const size = parseFloat(cs.fontSize)
61
+ const lh = parseFloat(cs.lineHeight)
62
+ const leading = Number.isNaN(lh) ? cs.lineHeight : `${+lh.toFixed(1)}px (${(lh / size).toFixed(2)}×)`
63
+ const ls = parseFloat(cs.letterSpacing)
64
+ const tracking = !ls ? '0' : `${cs.letterSpacing} (${(ls / size).toFixed(3)}em)`
65
+ return { fam, typeface, width, weight: `${w}${wname ? ` ${wname}` : ''}`, size: `${+size.toFixed(1)}px`, leading, tracking }
66
+ }
67
+
68
+ function SpecimenRow({ className, sample, label, sampleClassName, metaConfig }) {
69
+ const ref = useRef(null)
70
+ const [m, setM] = useState(null)
71
+
72
+ useLayoutEffect(() => {
73
+ const measure = () => { if (ref.current) setM(readMeta(getComputedStyle(ref.current), metaConfig)) }
74
+ measure()
75
+ // Re-measure once webfonts land — line-height can resolve as `normal`
76
+ // (font-dependent) before the face loads, which would print a stale leading.
77
+ let cancelled = false
78
+ if (typeof document !== 'undefined' && document.fonts?.ready) {
79
+ document.fonts.ready.then(() => { if (!cancelled) measure() })
80
+ }
81
+ return () => { cancelled = true }
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ }, [className])
84
+
85
+ return (
86
+ <div className="flex flex-col gap-2 py-4 border-b border-fg-08">
87
+ <div ref={ref} className={sampleClassName ? `${className} ${sampleClassName}` : className}>{sample}</div>
88
+ {m && (
89
+ <div className="kol-mono-10 text-meta flex flex-col gap-0.5">
90
+ <span className="text-body">{label ?? className}</span>
91
+ <span>{m.typeface} · {m.fam} · {m.width} · {m.weight}</span>
92
+ <span>{m.size} · leading {m.leading} · tracking {m.tracking}</span>
93
+ </div>
94
+ )}
95
+ </div>
96
+ )
97
+ }
98
+
99
+ export default function TypeSpecimenLive({
100
+ specs = [],
101
+ title,
102
+ description,
103
+ sampleClassName = 'text-emphasis',
104
+ sansWeightNames = DEFAULT_SANS_WEIGHTS,
105
+ monoWeightNames = DEFAULT_MONO_WEIGHTS,
106
+ typefaceNames = DEFAULT_TYPEFACE_NAMES,
107
+ className = '',
108
+ }) {
109
+ const metaConfig = { sansWeightNames, monoWeightNames, typefaceNames }
110
+ return (
111
+ <div className={`kol-type-specimen-live flex flex-col ${className}`}>
112
+ {(title || description) && (
113
+ <header className="mb-4">
114
+ {title && <h2 className="kol-sans-heading-04 text-emphasis mb-3">{title}</h2>}
115
+ {description && <p className="kol-sans-body-03 text-meta">{description}</p>}
116
+ </header>
117
+ )}
118
+ {specs.map((s, i) => (
119
+ <SpecimenRow
120
+ key={s.className ? `${s.className}-${i}` : i}
121
+ className={s.className}
122
+ sample={s.sample}
123
+ label={s.label}
124
+ sampleClassName={sampleClassName}
125
+ metaConfig={metaConfig}
126
+ />
127
+ ))}
128
+ </div>
129
+ )
130
+ }
@@ -111,7 +111,6 @@ const TypefaceVariablePreview = ({
111
111
  max={200}
112
112
  value={size}
113
113
  onChange={setSize}
114
- variant="minimal"
115
114
  className="flex-1"
116
115
  />
117
116
  <Slider
@@ -120,7 +119,6 @@ const TypefaceVariablePreview = ({
120
119
  max={50}
121
120
  value={leading}
122
121
  onChange={setLeading}
123
- variant="minimal"
124
122
  className="flex-1"
125
123
  formatValue={(val) => 90 + val}
126
124
  />
@@ -130,7 +128,6 @@ const TypefaceVariablePreview = ({
130
128
  max={50}
131
129
  value={spacing}
132
130
  onChange={setSpacing}
133
- variant="minimal"
134
131
  className="flex-1"
135
132
  />
136
133
  </div>
@@ -144,7 +144,6 @@ const VariableFontSection = ({
144
144
  max={maxWeight}
145
145
  value={Math.round(weight)}
146
146
  onChange={handleSliderChange}
147
- variant="minimal"
148
147
  className="w-full"
149
148
  />
150
149
  </div>
package/src/index.js CHANGED
@@ -25,6 +25,10 @@ export { default as TypefaceVariablePreview } from './TypefaceVariablePreview.js
25
25
  // type-specimen kit — prop-driven specimen blocks (moved from @kolkrabbi/kol-component 2026-07-09)
26
26
  export { default as TypeSample } from './TypeSample.jsx'
27
27
  export { default as TypeSpecCard } from './TypeSpecCard.jsx'
28
+ export { default as TypeSpecimenLive, readMeta } from './TypeSpecimenLive.jsx'
29
+
30
+ // font-metric hook — opentype.js parsing (optional peer, degrades gracefully)
31
+ export { default as useFontMetrics, extractFaceMetrics, buildOutlinePaths, glyphMetricsOf } from './useFontMetrics.js'
28
32
 
29
33
  // live-font effects — variable-font axes, animated
30
34
  export { default as TextPressure } from './TextPressure.jsx'
@@ -0,0 +1,251 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+
3
+ /**
4
+ * useFontMetrics — parse a real font with opentype.js and expose its metrics +
5
+ * text→outline geometry. The parsing engine ported from the kol type-editor's
6
+ * `fontLoader.js` (async fetch + parse + promise cache) and `textOutline.js`
7
+ * (tracking-aware advance measurement, greedy soft-wrap, CSS half-leading
8
+ * baseline math). Generalised off the editor's `layer` object into plain args
9
+ * and stripped of the editor's case-folding — casing is a content concern, so
10
+ * text is measured and outlined verbatim.
11
+ *
12
+ * WHY IT EXISTS: `GlyphMetricsGrid` already injects a FontFace and parses face-
13
+ * level metrics (ascender/descender/cap/x-height) inline. This hook is the
14
+ * reusable, parse-only layer beneath that — and it adds what the grid can't
15
+ * currently show: per-glyph advance/bounding-box readouts and true glyph
16
+ * OUTLINE path data (`<path d>`) for any string, at any size/tracking, wrapped
17
+ * to a box. A consumer can feed those straight into an SVG specimen.
18
+ *
19
+ * GRACEFUL DEGRADATION: opentype.js is an OPTIONAL peer, loaded via dynamic
20
+ * import. Absent it, the hook resolves to `status: 'unavailable'` with a null
21
+ * font — every derived helper returns an empty/zero result instead of throwing,
22
+ * exactly like the grid's overlay simply not drawing.
23
+ *
24
+ * @param {string} [fontUrl] Same-origin URL of a `.ttf`/`.otf` to fetch + parse.
25
+ * @param {Object} [options]
26
+ * @param {ArrayBuffer} [options.buffer] Pre-fetched font bytes — skips the fetch
27
+ * (use when a caller already holds the buffer, e.g. an upload). Takes priority
28
+ * over `fontUrl`; the two share the same parse/metric path.
29
+ * @param {string} [options.cacheKey] Override the cache key when passing a raw
30
+ * buffer (defaults to a per-buffer identity key, so repeat buffers re-parse).
31
+ * @returns {{
32
+ * status: 'idle'|'loading'|'ready'|'unavailable'|'error',
33
+ * opentypeAvailable: boolean,
34
+ * font: object|null,
35
+ * metrics: {unitsPerEm:number,ascender:number,descender:number,capHeight:number,xHeight:number}|null,
36
+ * error: Error|null,
37
+ * getAdvanceWidth: (text:string, size:number, opts?:{tracking?:number}) => number,
38
+ * getGlyphMetrics: (char:string, size?:number) => object|null,
39
+ * getOutlinePaths: (text:string, opts?:object) => string[],
40
+ * }}
41
+ */
42
+
43
+ /* ── opentype loader — cached dynamic import; null once we know it's absent ──
44
+ * Mirrors GlyphMetricsGrid's inline dynamic import so the two agree on how the
45
+ * optional peer is resolved (mod.parse ?? mod.default.parse ?? mod.default). */
46
+ let opentypePromise = null
47
+ function loadOpentype() {
48
+ if (opentypePromise) return opentypePromise
49
+ opentypePromise = import('opentype.js')
50
+ .then((mod) => ({ parse: mod.parse || mod.default?.parse || mod.default }))
51
+ .catch(() => null)
52
+ return opentypePromise
53
+ }
54
+
55
+ /* Resolved { font, metrics } promises, keyed like fontLoader's cache so repeat
56
+ * calls for the same cut never re-fetch or re-parse. A rejected/absent parse is
57
+ * evicted so a later attempt can retry. */
58
+ const cache = new Map()
59
+
60
+ /* Face-level metric extraction with the SAME fallback chains GlyphMetricsGrid
61
+ * uses (os2 ?? hhea ?? literal) so an incomplete font degrades instead of
62
+ * throwing. */
63
+ function extractFaceMetrics(font) {
64
+ const os2 = font.tables?.os2
65
+ const hhea = font.tables?.hhea
66
+ return {
67
+ unitsPerEm: font.unitsPerEm || 1000,
68
+ ascender: os2?.sTypoAscender ?? hhea?.ascender ?? 800,
69
+ descender: os2?.sTypoDescender ?? hhea?.descender ?? -200,
70
+ capHeight: os2?.sCapHeight ?? 700,
71
+ xHeight: os2?.sxHeight ?? 500,
72
+ }
73
+ }
74
+
75
+ async function loadFontMetrics(key, source) {
76
+ if (cache.has(key)) return cache.get(key)
77
+ const promise = (async () => {
78
+ const ot = await loadOpentype()
79
+ if (!ot?.parse) return { font: null, metrics: null, reason: 'unavailable' }
80
+ const buffer =
81
+ source.buffer ??
82
+ (await fetch(source.url).then((r) => {
83
+ if (!r.ok) throw new Error(`Font fetch failed: ${source.url}`)
84
+ return r.arrayBuffer()
85
+ }))
86
+ const font = ot.parse(buffer)
87
+ return { font, metrics: extractFaceMetrics(font), reason: 'ready' }
88
+ })()
89
+ cache.set(key, promise)
90
+ promise.catch(() => cache.delete(key))
91
+ return promise
92
+ }
93
+
94
+ /* opentype render options for an em tracking value. Non-zero tracking clears
95
+ * ligature features — matching CSS ("UAs should not apply optional ligatures
96
+ * when letter-spacing is non-zero"); kerning stays on via the defaults. */
97
+ const renderOpts = (track) =>
98
+ !track ? {} : { letterSpacing: track, features: [] }
99
+
100
+ /* Greedy soft-wrap of one hard line to maxW, measured with the same advance/
101
+ * kerning/letter-spacing engine that draws the glyphs. Whitespace never forces
102
+ * a break (trailing space hangs); a word wider than the box splits at char
103
+ * level (overflow-wrap: break-word). Ported verbatim from textOutline.js. */
104
+ function wrapLine(font, text, size, opts, maxW) {
105
+ if (!text.trim() || maxW <= 0) return [text]
106
+ const measure = (s) => font.getAdvanceWidth(s.replace(/\s+$/, ''), size, opts)
107
+ const tokens = text.split(/(\s+)/).filter(Boolean)
108
+ const lines = []
109
+ let cur = ''
110
+ for (const tok of tokens) {
111
+ if (/^\s+$/.test(tok)) { cur += tok; continue }
112
+ if (cur.trim() && measure(cur + tok) > maxW) { lines.push(cur); cur = tok }
113
+ else cur += tok
114
+ while (measure(cur) > maxW && cur.trim().length > 1) {
115
+ let n = cur.length - 1
116
+ while (n > 1 && measure(cur.slice(0, n)) > maxW) n -= 1
117
+ lines.push(cur.slice(0, n))
118
+ cur = cur.slice(n)
119
+ }
120
+ }
121
+ lines.push(cur)
122
+ return lines
123
+ }
124
+
125
+ /**
126
+ * Glyph outlines as layer-local `<path d>` strings, one per rendered line.
127
+ * Ported from textOutline.js `textOutlinePaths` — baselines via the CSS half-
128
+ * leading model over hhea ascent/descent (what Chrome/mac uses for the line-box
129
+ * content area). Text is outlined verbatim (no case-folding). When `boxWidth`
130
+ * is 0 the text is treated as a single unwrapped line; when `boxHeight` is 0 the
131
+ * block is top-anchored (top = 0) rather than vertically centered.
132
+ */
133
+ function buildOutlinePaths(font, metrics, text, opts = {}) {
134
+ const {
135
+ size = 96,
136
+ tracking = 0,
137
+ lineHeight = 1.05,
138
+ align = 'left',
139
+ boxWidth = 0,
140
+ boxHeight = 0,
141
+ decimals = 2,
142
+ } = opts
143
+ const display = String(text ?? '')
144
+ if (!display.trim()) return []
145
+
146
+ const ropts = renderOpts(tracking)
147
+ const lines = display
148
+ .split('\n')
149
+ .flatMap((ln) => (boxWidth > 0 ? wrapLine(font, ln, size, ropts, boxWidth) : [ln]))
150
+
151
+ const scale = size / metrics.unitsPerEm
152
+ const ascent = metrics.ascender * scale
153
+ const descent = -metrics.descender * scale /* hhea descender is negative */
154
+ const lineH = lineHeight * size
155
+ const blockH = Math.max(lines.length * lineH, size) /* min-height: 1em */
156
+ const top = boxHeight > 0 ? (boxHeight - blockH) / 2 : 0
157
+
158
+ const paths = []
159
+ lines.forEach((line, i) => {
160
+ const t = line.replace(/\s+$/, '') /* trailing spaces hang */
161
+ if (!t) return /* blank line still holds its slot */
162
+ const lineW = font.getAdvanceWidth(t, size, ropts)
163
+ const lx =
164
+ align === 'left' ? 0 : align === 'right' ? boxWidth - lineW : (boxWidth - lineW) / 2
165
+ const baseY = top + i * lineH + (lineH - (ascent + descent)) / 2 + ascent
166
+ const d = font.getPath(t, lx, baseY, size, ropts).toPathData(decimals)
167
+ if (d) paths.push(d)
168
+ })
169
+ return paths
170
+ }
171
+
172
+ /* Per-glyph readout for the first char of `char`, in font units and (if a size
173
+ * is given) scaled px. Upgrades GlyphMetricsGrid: it can now print each cell's
174
+ * real advance + bounding box, not just the face-level lines. */
175
+ function glyphMetricsOf(font, char, size) {
176
+ const ch = String(char ?? '').charAt(0)
177
+ if (!ch) return null
178
+ const glyph = font.charToGlyph(ch)
179
+ if (!glyph) return null
180
+ const bbox = glyph.getBoundingBox?.() ?? { x1: 0, y1: 0, x2: 0, y2: 0 }
181
+ const advanceUnits = glyph.advanceWidth ?? 0
182
+ const base = {
183
+ name: glyph.name,
184
+ unicode: glyph.unicode,
185
+ index: glyph.index,
186
+ advanceWidth: advanceUnits,
187
+ boundingBox: bbox,
188
+ leftSideBearing: glyph.leftSideBearing ?? bbox.x1,
189
+ }
190
+ if (!size) return base
191
+ const scale = size / (font.unitsPerEm || 1000)
192
+ return {
193
+ ...base,
194
+ advanceWidthPx: advanceUnits * scale,
195
+ widthPx: (bbox.x2 - bbox.x1) * scale,
196
+ heightPx: (bbox.y2 - bbox.y1) * scale,
197
+ }
198
+ }
199
+
200
+ export default function useFontMetrics(fontUrl, options = {}) {
201
+ const { buffer, cacheKey } = options
202
+ const key = buffer ? cacheKey ?? `buffer:${cacheKey ?? Math.random()}` : fontUrl
203
+
204
+ const [state, setState] = useState({
205
+ status: 'idle',
206
+ font: null,
207
+ metrics: null,
208
+ error: null,
209
+ })
210
+
211
+ useEffect(() => {
212
+ if (!buffer && !fontUrl) {
213
+ setState({ status: 'idle', font: null, metrics: null, error: null })
214
+ return
215
+ }
216
+ let cancelled = false
217
+ setState((s) => ({ ...s, status: 'loading', error: null }))
218
+ loadFontMetrics(key, buffer ? { buffer } : { url: fontUrl })
219
+ .then(({ font, metrics, reason }) => {
220
+ if (cancelled) return
221
+ if (!font) { setState({ status: 'unavailable', font: null, metrics: null, error: null }); return }
222
+ setState({ status: reason === 'ready' ? 'ready' : 'unavailable', font, metrics, error: null })
223
+ })
224
+ .catch((error) => {
225
+ if (!cancelled) setState({ status: 'error', font: null, metrics: null, error })
226
+ })
227
+ return () => { cancelled = true }
228
+ // eslint-disable-next-line react-hooks/exhaustive-deps
229
+ }, [key])
230
+
231
+ const { font, metrics } = state
232
+
233
+ // Stable helper identities per parsed font — safe to pass to children/memos.
234
+ const helpers = useMemo(() => ({
235
+ getAdvanceWidth: (text, size, opts = {}) =>
236
+ font ? font.getAdvanceWidth(String(text ?? ''), size, renderOpts(opts.tracking)) : 0,
237
+ getGlyphMetrics: (char, size) => (font ? glyphMetricsOf(font, char, size) : null),
238
+ getOutlinePaths: (text, opts) => (font && metrics ? buildOutlinePaths(font, metrics, text, opts) : []),
239
+ }), [font, metrics])
240
+
241
+ return {
242
+ status: state.status,
243
+ opentypeAvailable: state.status !== 'unavailable',
244
+ font,
245
+ metrics,
246
+ error: state.error,
247
+ ...helpers,
248
+ }
249
+ }
250
+
251
+ export { extractFaceMetrics, buildOutlinePaths, glyphMetricsOf }