@kolkrabbi/kol-foundry 0.4.2 → 0.5.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.
@@ -0,0 +1,169 @@
1
+ // =============================================================================
2
+ // hyperflip/MetricsOverlay.js
3
+ // =============================================================================
4
+
5
+ export class MetricsOverlay {
6
+ constructor(overlayElement) {
7
+ this.overlay = overlayElement;
8
+ this.isVisible = false;
9
+ this.overlay.style.display = 'none'; // Ensure it's hidden initially
10
+ }
11
+
12
+ render(font, glyphElement) {
13
+ if (!this.isVisible || !glyphElement) return;
14
+
15
+ this.overlay.innerHTML = '';
16
+
17
+ const currentChar = glyphElement.textContent;
18
+ if (!currentChar) return;
19
+
20
+ const metrics = this.calculateMetrics(font, glyphElement, currentChar);
21
+ this.renderMetricLines(metrics, font);
22
+ this.renderBearingLines(metrics);
23
+ }
24
+
25
+ calculateMetrics(font, glyphElement, currentChar) {
26
+ const computedStyle = getComputedStyle(glyphElement);
27
+ const fontSize = parseInt(computedStyle.fontSize);
28
+ const unitsPerEm = font.unitsPerEm;
29
+
30
+ // Basic metrics scale based on font size
31
+ const metricsScale = fontSize / unitsPerEm;
32
+
33
+ // Get the glyph element's metrics
34
+ const glyphRect = glyphElement.getBoundingClientRect();
35
+ const renderedWidth = glyphRect.width;
36
+ const verticalCenter = glyphRect.top + (glyphRect.height / 2);
37
+ const horizontalCenter = glyphRect.left + (glyphRect.width / 2);
38
+
39
+ // Get glyph info
40
+ const glyphIndex = font.charToGlyphIndex(currentChar);
41
+ const glyph = font.glyphs.get(glyphIndex);
42
+
43
+ // The total height in font units from descender to ascender
44
+ const totalHeight = font.tables.os2.sTypoAscender - font.tables.os2.sTypoDescender;
45
+
46
+ // Calculate how much of the total height is above the baseline (in font units)
47
+ const baselineOffset = font.tables.os2.sTypoAscender;
48
+
49
+ // Calculate where the baseline should be relative to the center
50
+ const baselineRatio = baselineOffset / totalHeight;
51
+
52
+ // Scale the total height to pixels
53
+ const totalPixelHeight = (totalHeight / unitsPerEm) * fontSize;
54
+
55
+ // Position the baseline
56
+ const baseline = verticalCenter - (totalPixelHeight / 2) + (baselineRatio * totalPixelHeight);
57
+
58
+ return {
59
+ glyph,
60
+ fontSize,
61
+ metricsScale,
62
+ glyphRect,
63
+ containerCenter: horizontalCenter,
64
+ baseline,
65
+ renderedWidth,
66
+ ascender: baseline - (font.tables.os2.sTypoAscender * metricsScale),
67
+ descender: baseline - (font.tables.os2.sTypoDescender * metricsScale),
68
+ capHeight: baseline - (font.tables.os2.sCapHeight * metricsScale),
69
+ xHeight: baseline - (font.tables.os2.sxHeight * metricsScale),
70
+ // Store the original values from the font file
71
+ originalMetrics: {
72
+ ascender: font.tables.os2.sTypoAscender,
73
+ descender: font.tables.os2.sTypoDescender,
74
+ capHeight: font.tables.os2.sCapHeight,
75
+ xHeight: font.tables.os2.sxHeight,
76
+ smallCapHeight: font.tables.os2.sSmallCapHeight
77
+ }
78
+ };
79
+ }
80
+
81
+ renderMetricLines(metrics, font) {
82
+ const lines = [
83
+ {
84
+ pos: metrics.baseline,
85
+ label: 'Baseline',
86
+ value: 0 // Baseline is reference point 0
87
+ },
88
+ {
89
+ pos: metrics.ascender,
90
+ label: 'Ascender',
91
+ value: metrics.originalMetrics.ascender
92
+ },
93
+ {
94
+ pos: metrics.descender,
95
+ label: 'Descender',
96
+ value: metrics.originalMetrics.descender
97
+ },
98
+ {
99
+ pos: metrics.capHeight,
100
+ label: 'Capital height',
101
+ value: metrics.originalMetrics.capHeight
102
+ },
103
+ {
104
+ pos: metrics.xHeight,
105
+ label: 'x-height',
106
+ value: metrics.originalMetrics.xHeight
107
+ }
108
+ ];
109
+
110
+ if (font.tables.os2.sSmallCapHeight) {
111
+ lines.push({
112
+ pos: metrics.baseline - (font.tables.os2.sSmallCapHeight * metrics.metricsScale),
113
+ label: 'Small Caps',
114
+ value: metrics.originalMetrics.smallCapHeight
115
+ });
116
+ }
117
+
118
+ lines.forEach(({ pos, label, value }) => {
119
+ if (isFinite(pos)) {
120
+ const line = document.createElement('div');
121
+ line.className = 'metric-line';
122
+ line.style.top = `${pos}px`;
123
+
124
+ const legend = document.createElement('div');
125
+ legend.className = 'legend';
126
+ legend.style.top = `${pos - 21}px`;
127
+
128
+ // Create the formatted legend content
129
+ const labelText = document.createTextNode(`${label} → `);
130
+ const valueSpan = document.createElement('span');
131
+ valueSpan.className = 'monospaced';
132
+ valueSpan.textContent = `${value}`;
133
+
134
+ legend.appendChild(labelText);
135
+ legend.appendChild(valueSpan);
136
+
137
+ this.overlay.appendChild(line);
138
+ this.overlay.appendChild(legend);
139
+ }
140
+ });
141
+ }
142
+
143
+ renderBearingLines(metrics) {
144
+ if (!metrics.glyph) return;
145
+
146
+ // Use the rendered width for sidebearings, which takes into account both
147
+ // font size and any active variation settings
148
+ const leftPos = metrics.containerCenter - (metrics.renderedWidth / 2);
149
+ const rightPos = leftPos + metrics.renderedWidth;
150
+
151
+ [leftPos, rightPos].forEach(pos => {
152
+ if (isFinite(pos)) {
153
+ const line = document.createElement('div');
154
+ line.className = 'side-bearing-line';
155
+ line.style.left = `${pos}px`;
156
+ this.overlay.appendChild(line);
157
+ }
158
+ });
159
+ }
160
+
161
+ toggle() {
162
+ this.isVisible = !this.isVisible;
163
+ this.overlay.style.display = this.isVisible ? 'block' : 'none';
164
+ if (!this.isVisible) {
165
+ this.overlay.innerHTML = '';
166
+ }
167
+ }
168
+
169
+ }
@@ -0,0 +1,22 @@
1
+ // =============================================================================
2
+ // Types.js - Font variation axis definitions
3
+ // =============================================================================
4
+
5
+ export const AXIS_NAMES = {
6
+ 'wght': 'Weight',
7
+ 'wdth': 'Width',
8
+ 'ital': 'Italic',
9
+ 'slnt': 'Slant',
10
+ 'opsz': 'Optical Size',
11
+ 'GRAD': 'Grade',
12
+ 'YOPQ': 'Thin Stroke',
13
+ 'YTLC': 'Lowercase Height',
14
+ 'YTUC': 'Uppercase Height',
15
+ 'YTAS': 'Ascender Height',
16
+ 'YTDE': 'Descender Depth',
17
+ 'YTFI': 'Figure Height',
18
+ 'XHGT': 'x-Height',
19
+ 'XOPQ': 'Thick Stroke',
20
+ 'XTRA': 'Parametric Tracking',
21
+ 'YTRA': 'Parametric Tracking'
22
+ };
@@ -0,0 +1,92 @@
1
+ // =============================================================================
2
+ // shared/UIControls.js
3
+ // =============================================================================
4
+
5
+ export class UIControls {
6
+ constructor(options = {}) {
7
+ this.isDarkMode = false;
8
+ this.isFullscreen = false;
9
+ this.setupEventListeners();
10
+ }
11
+
12
+ setupEventListeners() {
13
+ // Fullscreen change events for different browsers
14
+ document.addEventListener('fullscreenchange', this.handleFullscreenChange.bind(this));
15
+ document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange.bind(this));
16
+ document.addEventListener('mozfullscreenchange', this.handleFullscreenChange.bind(this));
17
+ document.addEventListener('MSFullscreenChange', this.handleFullscreenChange.bind(this));
18
+ }
19
+
20
+ toggleFullscreen() {
21
+ if (this.isFullscreen) {
22
+ this.exitFullscreen();
23
+ } else {
24
+ this.enterFullscreen();
25
+ }
26
+ }
27
+
28
+ enterFullscreen() {
29
+ const elem = document.documentElement;
30
+ if (elem.requestFullscreen) {
31
+ elem.requestFullscreen();
32
+ } else if (elem.webkitRequestFullscreen) {
33
+ elem.webkitRequestFullscreen();
34
+ } else if (elem.msRequestFullscreen) {
35
+ elem.msRequestFullscreen();
36
+ }
37
+ }
38
+
39
+ exitFullscreen() {
40
+ if (document.exitFullscreen) {
41
+ document.exitFullscreen();
42
+ } else if (document.webkitExitFullscreen) {
43
+ document.webkitExitFullscreen();
44
+ } else if (document.msExitFullscreen) {
45
+ document.msExitFullscreen();
46
+ }
47
+ }
48
+
49
+ handleFullscreenChange() {
50
+ this.isFullscreen = Boolean(
51
+ document.fullscreenElement ||
52
+ document.webkitFullscreenElement ||
53
+ document.mozFullScreenElement ||
54
+ document.msFullscreenElement
55
+ );
56
+
57
+ // Update button text if needed
58
+ const fullscreenButton = document.querySelector('#fullScreen button');
59
+ if (fullscreenButton) {
60
+ fullscreenButton.textContent = this.isFullscreen ? 'Windowed' : 'Fullscreen';
61
+ }
62
+ }
63
+
64
+ toggleColorScheme() {
65
+ this.isDarkMode = !this.isDarkMode;
66
+ const wrapper = document.querySelector('.font-viewer-wrapper');
67
+
68
+ if (wrapper) {
69
+ if (this.isDarkMode) {
70
+ wrapper.style.setProperty('--fv-white', 'rgb(0, 0, 0)');
71
+ wrapper.style.setProperty('--fv-black', 'rgb(255, 255, 255)');
72
+ wrapper.style.setProperty('--fv-metrics-color', 'rgba(255, 255, 255, 0.4)');
73
+ wrapper.style.setProperty('--fv-border-color', 'rgba(255, 255, 255, 0.9)');
74
+ wrapper.style.setProperty('--fv-surface', 'rgba(0, 0, 0, 0.82)');
75
+ wrapper.style.setProperty('--fv-panel-bg', 'rgba(8, 8, 8, 0.9)');
76
+ wrapper.style.setProperty('--fv-panel-border', 'rgba(255, 255, 255, 0.9)');
77
+ wrapper.style.setProperty('--fv-panel-shadow', '0 10px 24px rgba(0, 0, 0, 0.6)');
78
+ wrapper.dataset.colorScheme = 'dark'
79
+ } else {
80
+ wrapper.style.setProperty('--fv-white', 'rgb(255, 255, 255)');
81
+ wrapper.style.setProperty('--fv-black', 'rgb(0, 0, 0)');
82
+ wrapper.style.setProperty('--fv-metrics-color', 'rgba(0, 0, 0, 0.4)');
83
+ wrapper.style.setProperty('--fv-border-color', 'var(--fv-black)');
84
+ wrapper.style.setProperty('--fv-surface', 'rgba(255, 255, 255, 0.92)');
85
+ wrapper.style.setProperty('--fv-panel-bg', 'rgba(255, 255, 255, 0.96)');
86
+ wrapper.style.setProperty('--fv-panel-border', 'var(--fv-black)');
87
+ wrapper.style.setProperty('--fv-panel-shadow', '0 10px 24px rgba(0, 0, 0, 0.18)');
88
+ wrapper.dataset.colorScheme = 'light'
89
+ }
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,71 @@
1
+ // =============================================================================
2
+ // hyperflip/VariationAxes.js
3
+ // =============================================================================
4
+
5
+ export class VariationAxes {
6
+ constructor(options) {
7
+ this.container = options.container
8
+ this.onChange = options.onChange
9
+ this.currentSettings = {}
10
+ }
11
+
12
+ createAxesControls(axes) {
13
+ if (!this.container) {
14
+ return
15
+ }
16
+
17
+ this.container.innerHTML = ''
18
+ this.currentSettings = {}
19
+
20
+ axes.forEach(axis => {
21
+ const sliderGroup = document.createElement('div')
22
+ sliderGroup.className = 'font-viewer-slider-group'
23
+
24
+ const label = document.createElement('label')
25
+ const sliderId = `font-axis-${axis.tag}`
26
+ label.htmlFor = sliderId
27
+ label.textContent = `${axis.name} (${axis.tag})`
28
+
29
+ const slider = document.createElement('input')
30
+ slider.type = 'range'
31
+ slider.id = sliderId
32
+ slider.dataset.tag = axis.tag
33
+ slider.min = axis.min
34
+ slider.max = axis.max
35
+ slider.value = axis.default
36
+ slider.step = 0.1
37
+
38
+ const valueSpan = document.createElement('span')
39
+ valueSpan.textContent = parseFloat(axis.default).toFixed(1)
40
+
41
+ slider.addEventListener('input', (e) => {
42
+ this.updateAxisValue(axis.tag, e.target.value)
43
+ valueSpan.textContent = parseFloat(e.target.value).toFixed(1)
44
+ })
45
+
46
+ sliderGroup.appendChild(label)
47
+ sliderGroup.appendChild(slider)
48
+ sliderGroup.appendChild(valueSpan)
49
+
50
+ this.container.appendChild(sliderGroup)
51
+ })
52
+ }
53
+
54
+ updateAxisValue(tag, value) {
55
+ const numValue = parseFloat(value)
56
+ if (isNaN(numValue)) {
57
+ delete this.currentSettings[tag]
58
+ } else {
59
+ this.currentSettings[tag] = numValue
60
+ }
61
+ this.updateVariationSettings()
62
+ }
63
+
64
+ updateVariationSettings() {
65
+ const settings = Object.entries(this.currentSettings)
66
+ .filter(([_, val]) => !isNaN(val))
67
+ .map(([tag, val]) => `"${tag}" ${val.toFixed(1)}`)
68
+ .join(', ')
69
+ this.onChange?.(settings || 'normal')
70
+ }
71
+ }
package/src/index.js CHANGED
@@ -40,3 +40,21 @@ export { default as TypefaceSpecimenPage } from './TypefaceSpecimenPage.jsx'
40
40
  // data
41
41
  export { glyphSets, glyphCategories, SPECIMEN_SAMPLE_TEXT } from './glyphData.js'
42
42
  export { typefaceConfig, getTypefaceConfig, getAllTypefaceIds, getAllTypefaces } from './typefaceConfig.js'
43
+
44
+ // fontviewer engine (lobbied from elder @kol/fontviewer 2026-07-16) — the
45
+ // variable-font viewer instrument: canvas glyph stage + parsed-metrics overlay
46
+ // + axis controls. Plain-JS utils are React-free; opentype.js is the optional
47
+ // peer. Chrome CSS lives in kol-theme/kol-components-foundry.css. Font files
48
+ // are consumer-served (never bundled) — pass fontUrl/defaultFontUrl props.
49
+ export { default as FontViewerComponent } from './engine/FontViewerComponent.jsx'
50
+ export { default as FontViewerSection } from './engine/FontViewerSection.jsx'
51
+ export * from './engine/FontLoader.js'
52
+ export * from './engine/FontInfo.js'
53
+ export * from './engine/GlyphAnimator.js'
54
+ export * from './engine/MetricsOverlay.js'
55
+ export * from './engine/Types.js'
56
+ export * from './engine/UIControls.js'
57
+ export * from './engine/VariationAxes.js'
58
+
59
+ // glyph atom (lobbied from elder @kol/ui 2026-07-16) — renders one live glyph
60
+ export { default as GlyphItem } from './GlyphItem.jsx'