@kolkrabbi/kol-component 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +11 -3
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/atoms/Button.jsx +17 -6
  5. package/src/atoms/CurveOverlay.jsx +180 -0
  6. package/src/atoms/DocsToc.jsx +48 -0
  7. package/src/atoms/EmptyState.jsx +22 -0
  8. package/src/atoms/Figure.jsx +27 -0
  9. package/src/atoms/HlsVideo.jsx +56 -0
  10. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  11. package/src/atoms/PriceDisplay.jsx +34 -0
  12. package/src/atoms/ProsePreview.jsx +53 -0
  13. package/src/atoms/RotaryDial.jsx +150 -0
  14. package/src/atoms/SearchInput.jsx +108 -0
  15. package/src/atoms/TextPressure.jsx +331 -0
  16. package/src/atoms/TiltCard.jsx +127 -0
  17. package/src/atoms/TypeSample.jsx +50 -0
  18. package/src/atoms/TypeSpecCard.jsx +42 -0
  19. package/src/hooks/cssVar.js +53 -0
  20. package/src/hooks/useAxisAnimation.js +91 -0
  21. package/src/hooks/usePrefersReducedMotion.js +21 -0
  22. package/src/hooks/useTilt.js +49 -0
  23. package/src/index.js +66 -1
  24. package/src/molecules/AlignmentGrid.jsx +53 -0
  25. package/src/molecules/ArticleCard.jsx +178 -0
  26. package/src/molecules/CardFeatureItem.jsx +130 -0
  27. package/src/molecules/ColorInputRow.jsx +179 -0
  28. package/src/molecules/ColorRamp.jsx +114 -0
  29. package/src/molecules/FramedMediaBand.jsx +56 -0
  30. package/src/molecules/ImageBlock.jsx +34 -0
  31. package/src/molecules/SelectionOverlay.jsx +108 -0
  32. package/src/molecules/ShapeDropdown.jsx +92 -0
  33. package/src/molecules/ShellDrawer.jsx +169 -0
  34. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  35. package/src/molecules/SpecList.jsx +30 -0
  36. package/src/molecules/SpectrumControls.jsx +504 -0
  37. package/src/molecules/SwatchControls.jsx +217 -0
  38. package/src/molecules/TabsRow.jsx +87 -0
  39. package/src/molecules/VideoBlock.jsx +86 -0
  40. package/src/molecules/WorkListItem.jsx +83 -0
  41. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  42. package/src/organisms/ArticleHeader.jsx +95 -0
  43. package/src/organisms/AsciiCursor.jsx +526 -0
  44. package/src/organisms/BentoCard.jsx +187 -0
  45. package/src/organisms/Canvas.jsx +299 -0
  46. package/src/organisms/ColorLoader.jsx +155 -0
  47. package/src/organisms/CtaGlobal.jsx +67 -0
  48. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  49. package/src/organisms/EditorShell.jsx +111 -0
  50. package/src/organisms/ErrorBoundary.jsx +70 -0
  51. package/src/organisms/FeatureSplit.jsx +82 -0
  52. package/src/organisms/FeaturedCarousel.jsx +258 -0
  53. package/src/organisms/FeaturesCardSection.jsx +90 -0
  54. package/src/organisms/FullBleedHero.jsx +111 -0
  55. package/src/organisms/GalleryCarousel.jsx +83 -0
  56. package/src/organisms/LoaderOverlay.jsx +30 -0
  57. package/src/organisms/MediaViewer.jsx +95 -0
  58. package/src/organisms/NewsletterBand.jsx +122 -0
  59. package/src/organisms/ParallaxShelf.jsx +141 -0
  60. package/src/organisms/PortableTextRenderer.jsx +115 -0
  61. package/src/organisms/ProductDetailLayout.jsx +189 -0
  62. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  63. package/src/organisms/SpectrumGrid.jsx +90 -0
  64. package/src/organisms/StackHero.jsx +83 -0
  65. package/src/organisms/WorkCard.jsx +120 -0
  66. package/src/organisms/WorkViewToggle.jsx +170 -0
  67. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  68. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  69. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  70. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  71. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  72. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  73. package/src/organisms/foundry/glyphData.js +30 -0
  74. package/src/organisms/foundry/index.js +21 -0
@@ -0,0 +1,187 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import Dropdown from '../../molecules/Dropdown.jsx'
3
+ import Slider from '../../molecules/Slider.jsx'
4
+ import SpecimenSectionHeader from '../../molecules/foundry/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 '../../atoms/Button.jsx'
3
+ import SpecimenSectionHeader from '../../molecules/foundry/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 '../../atoms/Tag.jsx'
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 &amp; Latin
315
+ </Tag>
316
+ <Tag hash={false} active={activeTab === 'lowercase'} onClick={() => setActiveTab('lowercase')}>
317
+ Lowercase &amp; 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