/** * @sc4rfurryx/proteusjs/typography * Fluid typography with CSS-first approach * * @version 2.0.0 * @author sc4rfurry * @license MIT */ export interface FluidTypeOptions { minViewportPx?: number; maxViewportPx?: number; lineHeight?: number; containerUnits?: boolean; } export interface FluidTypeResult { css: string; } /** * Generate pure-CSS clamp() rules for fluid typography */ export function fluidType( minRem: number, maxRem: number, options: FluidTypeOptions = {} ): FluidTypeResult { const { minViewportPx = 320, maxViewportPx = 1200, lineHeight, containerUnits = false } = options; // Convert rem to px for calculations (assuming 16px base) const minPx = minRem * 16; const maxPx = maxRem * 16; // Calculate slope and y-intercept for linear interpolation const slope = (maxPx - minPx) / (maxViewportPx - minViewportPx); const yIntercept = minPx - slope * minViewportPx; // Generate clamp() function const viewportUnit = containerUnits ? 'cqw' : 'vw'; const clampValue = `clamp(${minRem}rem, ${yIntercept / 16}rem + ${slope * 100}${viewportUnit}, ${maxRem}rem)`; let css = `font-size: ${clampValue};`; // Add line-height if specified if (lineHeight) { css += `\nline-height: ${lineHeight};`; } return { css }; } /** * Apply fluid typography to elements */ export function applyFluidType( selector: string, minRem: number, maxRem: number, options: FluidTypeOptions = {} ): void { const { css } = fluidType(minRem, maxRem, options); const styleElement = document.createElement('style'); styleElement.textContent = `${selector} {\n ${css.replace(/\n/g, '\n ')}\n}`; styleElement.setAttribute('data-proteus-typography', selector); document.head.appendChild(styleElement); } /** * Create a complete typographic scale */ export function createTypographicScale( baseSize: number = 1, ratio: number = 1.25, steps: number = 6, options: FluidTypeOptions = {} ): Record { const scale: Record = {}; for (let i = -2; i <= steps - 3; i++) { const size = baseSize * Math.pow(ratio, i); const minSize = size * 0.8; // 20% smaller at min viewport const maxSize = size * 1.2; // 20% larger at max viewport const stepName = i <= 0 ? `small${Math.abs(i)}` : `large${i}`; scale[stepName] = fluidType(minSize, maxSize, options); } return scale; } /** * Generate CSS custom properties for a typographic scale */ export function generateScaleCSS( scale: Record, prefix: string = '--font-size' ): string { const cssVars = Object.entries(scale) .map(([name, result]) => ` ${prefix}-${name}: ${result.css.replace('font-size: ', '').replace(';', '')};`) .join('\n'); return `:root {\n${cssVars}\n}`; } /** * Optimize line height for readability */ export function optimizeLineHeight(fontSize: number, measure: number = 65): number { // Optimal line height based on font size and measure (characters per line) // Smaller fonts need more line height, larger fonts need less const baseLineHeight = 1.4; const sizeAdjustment = Math.max(0.1, Math.min(0.3, (1 - fontSize) * 0.5)); const measureAdjustment = Math.max(-0.1, Math.min(0.1, (65 - measure) * 0.002)); return baseLineHeight + sizeAdjustment + measureAdjustment; } /** * Calculate optimal font size for container width */ export function calculateOptimalSize( containerWidth: number, targetCharacters: number = 65, baseCharWidth: number = 0.5 ): number { // Calculate font size to achieve target characters per line const optimalFontSize = containerWidth / (targetCharacters * baseCharWidth); // Clamp to reasonable bounds (12px to 24px) return Math.max(0.75, Math.min(1.5, optimalFontSize)); } /** * Apply responsive typography to an element */ export function makeResponsive( target: Element | string, options: { minSize?: number; maxSize?: number; targetCharacters?: number; autoLineHeight?: boolean; } = {} ): void { const targetEl = typeof target === 'string' ? document.querySelector(target) : target; if (!targetEl) { throw new Error('Target element not found'); } const { minSize = 0.875, maxSize = 1.25, targetCharacters = 65, autoLineHeight = true } = options; // Apply fluid typography const { css } = fluidType(minSize, maxSize); const element = targetEl as HTMLElement; // Parse and apply CSS const styles = css.split(';').filter(Boolean); styles.forEach(style => { const [property, value] = style.split(':').map(s => s.trim()); if (property && value) { element.style.setProperty(property, value); } }); // Auto line height if enabled if (autoLineHeight) { const updateLineHeight = () => { const computedStyle = getComputedStyle(element); const fontSize = parseFloat(computedStyle.fontSize); const containerWidth = element.getBoundingClientRect().width; const charactersPerLine = containerWidth / (fontSize * 0.5); const optimalLineHeight = optimizeLineHeight(fontSize / 16, charactersPerLine); element.style.lineHeight = optimalLineHeight.toString(); }; updateLineHeight(); // Update on resize if ('ResizeObserver' in window) { const resizeObserver = new ResizeObserver(updateLineHeight); resizeObserver.observe(element); // Store cleanup function (element as any)._proteusTypographyCleanup = () => { resizeObserver.disconnect(); }; } } } /** * Remove applied typography styles */ export function cleanup(target?: Element | string): void { if (target) { const targetEl = typeof target === 'string' ? document.querySelector(target) : target; if (targetEl && (targetEl as any)._proteusTypographyCleanup) { (targetEl as any)._proteusTypographyCleanup(); delete (targetEl as any)._proteusTypographyCleanup; } } else { // Remove all typography style elements const styleElements = document.querySelectorAll('style[data-proteus-typography]'); styleElements.forEach(element => element.remove()); } } /** * Check if container query units are supported */ export function supportsContainerUnits(): boolean { return CSS.supports('width', '1cqw'); } // Export default object for convenience export default { fluidType, applyFluidType, createTypographicScale, generateScaleCSS, optimizeLineHeight, calculateOptimalSize, makeResponsive, cleanup, supportsContainerUnits };