├── .gitignore ├── tsconfig.json ├── package.json ├── README.md └── src ├── index.ts └── index.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .DS_Store 3 | node_modules 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "rootDir": "./src", 6 | "lib": ["es2017", "es7", "es6", "dom"], 7 | "declaration": true, 8 | "outDir": "dist", 9 | "strict": true, 10 | "esModuleInterop": true 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | "dist", 15 | "**/*.test.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "utopia-core", 3 | "version": "1.6.0", 4 | "description": "Utopia.fyi fluid calculations", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "prepublishOnly": "vitest run && tsc", 10 | "test": "vitest run", 11 | "test:watch": "vitest watch" 12 | }, 13 | "keywords": [], 14 | "author": "Trys Mudford", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "typescript": "^5.3.3", 18 | "vitest": "^1.2.2" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/trys/utopia-core.git" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Utopia Core 2 | 3 | The calculations behind [Utopia.fyi](https://utopia.fyi). Available in JS/TS. 4 | 5 | ## Documentation 6 | 7 | > Note: Complete documentation to follow in the coming weeks. 8 | 9 | ### `calculateTypeScale()` 10 | 11 | Create a fluid type scale between two widths, sizes and scales. Set the number of positive and negative steps, and whether you want the scale to be relative to the `viewport` or the `container`. 12 | 13 | If any step in the type scale fails [WCAG SC 1.4.4](https://www.w3.org/WAI/WCAG21/Understanding/resize-text.html), the viewports where the step fails to be zoomable to 200% are returned in `wcagViolation`. 14 | 15 | #### Schema 16 | 17 | ```ts 18 | type UtopiaTypeConfig = { 19 | minWidth: number; 20 | maxWidth: number; 21 | minFontSize: number; 22 | maxFontSize: number; 23 | minTypeScale: number; 24 | maxTypeScale: number; 25 | negativeSteps?: number; 26 | positiveSteps?: number; 27 | relativeTo?: UtopiaRelativeTo; 28 | labelStyle?: UtopiaLabelStyle; 29 | } 30 | 31 | type UtopiaStep = { 32 | step: number; 33 | label: string; 34 | minFontSize: number; 35 | maxFontSize: number; 36 | wcagViolation: { 37 | from: number; 38 | to: number; 39 | } | null; 40 | clamp: string; 41 | } 42 | 43 | calculateTypeScale(config: UtopiaTypeConfig): UtopiaStep[]; 44 | ``` 45 | 46 | #### Example 47 | ```ts 48 | calculateTypeScale({ 49 | minWidth: 320, 50 | maxWidth: 1240, 51 | minFontSize: 18, 52 | maxFontSize: 20, 53 | minTypeScale: 1.2, 54 | maxTypeScale: 1.25, 55 | positiveSteps: 5, 56 | negativeSteps: 2 57 | }); 58 | 59 | // [ 60 | // { 61 | // step: 5, 62 | // label: '5', 63 | // minFontSize: 44.79, 64 | // maxFontSize: 61.04, 65 | // wcagViolation: 1200, 66 | // clamp: 'clamp(2.7994rem, 2.4461rem + 1.7663vw, 3.815rem)', 67 | // } 68 | // ... 69 | // ] 70 | ``` 71 | 72 | ### `calculateSpaceScale()` 73 | 74 | Create a set of fluid spaces from min/max width/base sizes, and a number of positive/negative multipliers. Fluid spaces & one-up pairs are automatically created, and custom pairs can be created by supplying the keys you wish to interpolate between. Clamp provided in `rem` and `px`. 75 | 76 | #### Schema 77 | 78 | ```ts 79 | type UtopiaSpaceConfig = { 80 | minWidth: number; 81 | maxWidth: number; 82 | minSize: number; 83 | maxSize: number; 84 | negativeSteps?: number[]; 85 | positiveSteps?: number[]; 86 | customSizes?: string[]; 87 | relativeTo?: UtopiaRelativeTo; 88 | } 89 | 90 | type UtopiaSize = { 91 | label: string; 92 | minSize: number; 93 | maxSize: number; 94 | clamp: string; 95 | clampPx: string; 96 | } 97 | 98 | type UtopiaSpaceScale = { 99 | sizes: UtopiaSize[]; 100 | oneUpPairs: UtopiaSize[]; 101 | customPairs: UtopiaSize[]; 102 | }; 103 | 104 | calculateSpaceScale(config: UtopiaSpaceConfig): UtopiaSpaceScale; 105 | ``` 106 | 107 | #### Example 108 | 109 | ```ts 110 | calculateSpaceScale({ 111 | minWidth: 320, 112 | maxWidth: 1240, 113 | minSize: 18, 114 | maxSize: 20, 115 | positiveSteps: [1.5, 2, 3, 4, 6], 116 | negativeSteps: [0.75, 0.5, 0.25], 117 | customSizes: ['s-l', '2xl-4xl'] 118 | }); 119 | 120 | // { 121 | // sizes: [ 122 | // { 123 | // label: 's', 124 | // minSize: 18, 125 | // maxSize: 20, 126 | // clamp: 'clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem)', 127 | // clampPx: 'clamp(18px, 17.034px + 0.2174vw, 20rem)' 128 | // }, 129 | // ... 130 | // ], 131 | // oneUpPairs: [...], 132 | // customPairs: [...], 133 | // } 134 | ``` 135 | 136 | ### `calculateClamp` 137 | 138 | Generate a single clamp calculation from a min/max width & size. Default to using `rem` and `vi` but this can be overriden to use `px` and `cqi`. 139 | 140 | #### Schema 141 | 142 | ```ts 143 | type UtopiaClampConfig = { 144 | minWidth: number; 145 | maxWidth: number; 146 | minSize: number; 147 | maxSize: number; 148 | usePx?: boolean; 149 | relativeTo?: UtopiaRelativeTo; 150 | }; 151 | 152 | calculateClamp(UtopiaClampConfig): string; 153 | ``` 154 | 155 | #### Example 156 | 157 | ```ts 158 | calculateClamp({ 159 | minWidth: 320, 160 | maxWidth: 1240, 161 | minSize: 16, 162 | maxSize: 48, 163 | }) 164 | 165 | // clamp(...) 166 | ``` 167 | 168 | 169 | ### `calculateClamps` 170 | 171 | Generate multiple clamps from a single set of min/max widths. Supply an array of number pairs to interpolate between. 172 | 173 | #### Schema 174 | 175 | ```ts 176 | type UtopiaClampsConfig = { 177 | minWidth: number; 178 | maxWidth: number; 179 | pairs: [number, number][]; 180 | usePx?: boolean; 181 | relativeTo?: UtopiaRelativeTo; 182 | }; 183 | 184 | type UtopiaClamp = { 185 | label: string; 186 | clamp: string; 187 | }; 188 | 189 | calculateClamps(config: UtopiaClampsConfig): UtopiaClamp[] 190 | ``` 191 | 192 | #### Example 193 | 194 | ```ts 195 | calculateClamps({ 196 | minWidth: 320, 197 | maxWidth: 1240, 198 | pairs: [ 199 | [16, 48], 200 | [32, 40] 201 | ] 202 | }) 203 | 204 | // [ 205 | // { 206 | // label: '16-48', 207 | // clamp: 'clamp(...)', 208 | // }, 209 | // ... 210 | //] 211 | ``` 212 | 213 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | 3 | type UtopiaRelativeTo = 'viewport' | 'container' | 'viewport-width'; 4 | type UtopiaLabelStyle = 'utopia' | 'tailwind' | 'tshirt'; 5 | 6 | export type UtopiaTypeConfig = { 7 | minWidth: number; 8 | maxWidth: number; 9 | minFontSize: number; 10 | maxFontSize: number; 11 | minTypeScale: number; 12 | maxTypeScale: number; 13 | negativeSteps?: number; 14 | positiveSteps?: number; 15 | relativeTo?: UtopiaRelativeTo; 16 | labelStyle?: UtopiaLabelStyle 17 | } 18 | 19 | export type UtopiaStep = { 20 | step: number; 21 | label: string; 22 | minFontSize: number; 23 | maxFontSize: number; 24 | wcagViolation?: { 25 | from: number; 26 | to: number; 27 | } | null; 28 | clamp: string; 29 | } 30 | 31 | export type UtopiaSpaceConfig = { 32 | minWidth: number; 33 | maxWidth: number; 34 | minSize: number; 35 | maxSize: number; 36 | negativeSteps?: number[]; 37 | positiveSteps?: number[]; 38 | customSizes?: string[]; 39 | relativeTo?: UtopiaRelativeTo; 40 | } 41 | 42 | export type UtopiaSize = { 43 | label: string; 44 | minSize: number; 45 | maxSize: number; 46 | clamp: string; 47 | clampPx: string; 48 | multiplier: number; 49 | } 50 | 51 | export type UtopiaSpaceScale = { 52 | sizes: UtopiaSize[]; 53 | oneUpPairs: UtopiaSize[]; 54 | customPairs: UtopiaSize[]; 55 | }; 56 | 57 | export type UtopiaClampsConfig = { 58 | minWidth: number; 59 | maxWidth: number; 60 | pairs: [number, number][]; 61 | relativeTo?: UtopiaRelativeTo; 62 | }; 63 | 64 | export type UtopiaClampConfig = { 65 | minWidth: number; 66 | maxWidth: number; 67 | minSize: number; 68 | maxSize: number; 69 | usePx?: boolean; 70 | relativeTo?: UtopiaRelativeTo; 71 | }; 72 | 73 | export type UtopiaClamp = { 74 | label: string; 75 | clamp: string; 76 | } 77 | 78 | // Helpers 79 | 80 | const lerp = (x: number, y: number, a: number) => x * (1 - a) + y * a 81 | const clamp = (a: number, min: number = 0, max: number = 1) => Math.min(max, Math.max(min, a)) 82 | const invlerp = (x: number, y: number, a: number) => clamp((a - x) / (y - x)) 83 | const range = (x1: number, y1: number, x2: number, y2: number, a: number) => lerp(x2, y2, invlerp(x1, y1, a)) 84 | const roundValue = (n: number) => Math.round((n + Number.EPSILON) * 10000) / 10000; 85 | const sortNumberAscending = (a: number, b: number) => Number(a) - Number(b); 86 | 87 | // Clamp 88 | 89 | export const calculateClamp = ({ 90 | maxSize, 91 | minSize, 92 | minWidth, 93 | maxWidth, 94 | usePx = false, 95 | relativeTo = 'viewport-width' 96 | }: UtopiaClampConfig): string => { 97 | const isNegative = minSize > maxSize; 98 | const min = isNegative ? maxSize : minSize; 99 | const max = isNegative ? minSize : maxSize; 100 | 101 | const divider = usePx ? 1 : 16; 102 | const unit = usePx ? 'px' : 'rem'; 103 | const relativeUnits = { 104 | viewport: 'vi', 105 | 'viewport-width': 'vw', 106 | container: 'cqi' 107 | }; 108 | const relativeUnit = relativeUnits[relativeTo] || relativeUnits.viewport; 109 | 110 | const slope = ((maxSize / divider) - (minSize / divider)) / ((maxWidth / divider) - (minWidth / divider)); 111 | const intersection = (-1 * (minWidth / divider)) * slope + (minSize / divider); 112 | return `clamp(${roundValue(min / divider)}${unit}, ${roundValue(intersection)}${unit} + ${roundValue(slope * 100)}${relativeUnit}, ${roundValue(max / divider)}${unit})`; 113 | } 114 | 115 | /** 116 | * checkWCAG 117 | * Check if the clamp confirms to WCAG 1.4.4 118 | * Many thanks to Maxwell Barvian, creator of fluid.style for this calculation 119 | * @link https://barvian.me 120 | * @returns number[] | null 121 | */ 122 | export function checkWCAG({ min, max, minWidth, maxWidth }: { min: number, max: number, minWidth: number, maxWidth: number }): number[] | null { 123 | if (minWidth > maxWidth) { 124 | // need to flip because our checks assume minWidth < maxWidth 125 | const oldMinScreen = minWidth 126 | minWidth = maxWidth 127 | maxWidth = oldMinScreen 128 | 129 | const oldmin = min 130 | min = max 131 | max = oldmin 132 | } 133 | const slope = (max - min) / (maxWidth - minWidth) 134 | const intercept = min - (minWidth * slope) 135 | const lh = (5 * min - 2 * intercept) / (2 * slope) 136 | const rh = (5 * intercept - 2 * max) / (-1 * slope) 137 | const lh2 = 3 * intercept / slope 138 | 139 | // this assumes minWidth < maxWidth, hence the flip above 140 | // These were generated by creating piecewise functions for z5 (the font at 500% zoom in Chrome/Firefox) 141 | // and 2*z1 (2*the font at 100% zoom; the WCAG requirement), then solving for 142 | // z5 < 2*z1 143 | let failRange: number[] | null = [] 144 | if (maxWidth < 5 * minWidth) { 145 | if (minWidth < lh && lh < maxWidth) { 146 | failRange.push(Math.max(lh, minWidth), maxWidth) 147 | } 148 | if (5 * min < 2 * max) { 149 | failRange.push(maxWidth, 5 * minWidth) 150 | } 151 | if (5 * minWidth < rh && rh < 5 * maxWidth) { 152 | failRange.push(5 * minWidth, Math.min(rh, 5 * maxWidth)) 153 | } 154 | } else { 155 | if (minWidth < lh && lh < 5 * minWidth) { 156 | failRange.push(Math.max(lh, minWidth), 5 * minWidth) 157 | } 158 | if (5 * minWidth < lh2 && lh2 < maxWidth) { 159 | failRange.push(Math.max(lh2, 5 * minWidth), maxWidth) 160 | } 161 | if (maxWidth < rh && rh < 5 * maxWidth) { 162 | failRange.push(maxWidth, Math.min(rh, 5 * maxWidth)) 163 | } 164 | } 165 | 166 | // Clean up range 167 | if (failRange.length) { 168 | failRange = [failRange[0], failRange[failRange.length - 1]] 169 | if (Math.abs(failRange[1] - failRange[0]) < 0.1) failRange = null // rounding errors, ignore 170 | } 171 | 172 | return failRange; 173 | } 174 | 175 | export const calculateClamps = ({ minWidth, maxWidth, pairs = [], relativeTo }: UtopiaClampsConfig): UtopiaClamp[] => { 176 | return pairs.map(([minSize, maxSize]) => { 177 | return { 178 | label: `${minSize}-${maxSize}`, 179 | clamp: calculateClamp({ minSize, maxSize, minWidth, maxWidth, relativeTo }), 180 | clampPx: calculateClamp({ minSize, maxSize, minWidth, maxWidth, relativeTo, usePx: true }) 181 | } 182 | }); 183 | } 184 | 185 | // Type 186 | 187 | const calculateTypeSize = (config: UtopiaTypeConfig, viewport: number, step: number): number => { 188 | const scale = range(config.minWidth, config.maxWidth, config.minTypeScale, config.maxTypeScale, viewport); 189 | const fontSize = range(config.minWidth, config.maxWidth, config.minFontSize, config.maxFontSize, viewport); 190 | return fontSize * Math.pow(scale, step); 191 | } 192 | 193 | 194 | const mapStepToLabel = (step: number, labelGroup: UtopiaLabelStyle = "utopia") => { 195 | if (labelGroup === "utopia") return step.toString(); 196 | 197 | if (step < -2) return `${-1 * (step + 1)}xs`; 198 | if (step === -2) return "xs"; 199 | 200 | if (labelGroup === "tailwind") { 201 | if (step === -1) return "sm"; 202 | if (step === 0) return "base"; 203 | if (step === 1) return "lg"; 204 | } 205 | 206 | if (labelGroup === "tshirt") { 207 | if (step === -1) return "s"; 208 | if (step === 0) return "m"; 209 | if (step === 1) return "l"; 210 | } 211 | 212 | if (step === 2) return "xl"; 213 | if (step > 2) return `${step - 1}xl`; 214 | 215 | return step.toString(); 216 | } 217 | 218 | const calculateTypeStep = (config: UtopiaTypeConfig, step: number): UtopiaStep => { 219 | const minFontSize = calculateTypeSize(config, config.minWidth, step); 220 | const maxFontSize = calculateTypeSize(config, config.maxWidth, step); 221 | const wcag = checkWCAG({ min: minFontSize, max: maxFontSize, minWidth: config.minWidth, maxWidth: config.maxWidth }); 222 | 223 | return { 224 | step, 225 | label: mapStepToLabel(step, config.labelStyle), 226 | minFontSize: roundValue(minFontSize), 227 | maxFontSize: roundValue(maxFontSize), 228 | wcagViolation: wcag?.length ? { 229 | from: Math.round(wcag[0]), 230 | to: Math.round(wcag[1]), 231 | } : null, 232 | clamp: calculateClamp({ 233 | minSize: minFontSize, 234 | maxSize: maxFontSize, 235 | minWidth: config.minWidth, 236 | maxWidth: config.maxWidth, 237 | relativeTo: config.relativeTo 238 | }) 239 | } 240 | } 241 | 242 | export const calculateTypeScale = (config: UtopiaTypeConfig): UtopiaStep[] => { 243 | const positiveSteps = Array.from({ length: config.positiveSteps || 0 }) 244 | .map((_, i) => calculateTypeStep(config, i + 1)).reverse(); 245 | 246 | const negativeSteps = Array.from({ length: config.negativeSteps || 0 }) 247 | .map((_, i) => calculateTypeStep(config, -1 * (i + 1))); 248 | 249 | return [ 250 | ...positiveSteps, 251 | calculateTypeStep(config, 0), 252 | ...negativeSteps 253 | ] 254 | } 255 | 256 | // Space 257 | 258 | const calculateSpaceSize = (config: UtopiaSpaceConfig, multiplier: number, step: number): UtopiaSize => { 259 | const minSize = Math.round(config.minSize * multiplier); 260 | const maxSize = Math.round(config.maxSize * multiplier); 261 | 262 | let label = 'S'; 263 | if (step === 1) { 264 | label = 'M'; 265 | } else if (step === 2) { 266 | label = 'L'; 267 | } else if (step === 3) { 268 | label = 'XL'; 269 | } else if (step > 3) { 270 | label = `${step - 2}XL`; 271 | } else if (step === -1) { 272 | label = 'XS'; 273 | } else if (step < 0) { 274 | label = `${Math.abs(step)}XS`; 275 | } 276 | 277 | return { 278 | label: label.toLowerCase(), 279 | minSize: roundValue(minSize), 280 | maxSize: roundValue(maxSize), 281 | multiplier, 282 | clamp: calculateClamp({ 283 | minSize, 284 | maxSize, 285 | minWidth: config.minWidth, 286 | maxWidth: config.maxWidth, 287 | relativeTo: config.relativeTo, 288 | }), 289 | clampPx: calculateClamp({ 290 | minSize, 291 | maxSize, 292 | minWidth: config.minWidth, 293 | maxWidth: config.maxWidth, 294 | relativeTo: config.relativeTo, 295 | usePx: true, 296 | }) 297 | } 298 | } 299 | 300 | const calculateOneUpPairs = (config: UtopiaSpaceConfig, sizes: UtopiaSize[]): UtopiaSize[] => { 301 | return [...sizes.reverse()].map((size, i, arr) => { 302 | if (!i) return null; 303 | const prev = arr[i - 1]; 304 | return { 305 | label: `${prev.label}-${size.label}`, 306 | minSize: prev.minSize, 307 | maxSize: size.maxSize, 308 | clamp: calculateClamp({ 309 | minSize: prev.minSize, 310 | maxSize: size.maxSize, 311 | minWidth: config.minWidth, 312 | maxWidth: config.maxWidth, 313 | relativeTo: config.relativeTo, 314 | }), 315 | clampPx: calculateClamp({ 316 | minSize: prev.minSize, 317 | maxSize: size.maxSize, 318 | minWidth: config.minWidth, 319 | maxWidth: config.maxWidth, 320 | relativeTo: config.relativeTo, 321 | usePx: true, 322 | }), 323 | } 324 | }).filter((size): size is UtopiaSize => !!size) 325 | } 326 | 327 | const calculateCustomPairs = (config: UtopiaSpaceConfig, sizes: UtopiaSize[]): UtopiaSize[] => { 328 | return (config.customSizes || []).map((label) => { 329 | const [keyA, keyB] = label.split('-'); 330 | if (!keyA || !keyB) return null; 331 | 332 | const a = sizes.find(x => x.label === keyA); 333 | const b = sizes.find(x => x.label === keyB); 334 | if (!a || !b) return null; 335 | 336 | return { 337 | label: `${keyA}-${keyB}`, 338 | minSize: a.minSize, 339 | maxSize: b.maxSize, 340 | clamp: calculateClamp({ 341 | minWidth: config.minWidth, 342 | maxWidth: config.maxWidth, 343 | minSize: a.minSize, 344 | maxSize: b.maxSize, 345 | relativeTo: config.relativeTo, 346 | }), 347 | clampPx: calculateClamp({ 348 | minWidth: config.minWidth, 349 | maxWidth: config.maxWidth, 350 | minSize: a.minSize, 351 | maxSize: b.maxSize, 352 | relativeTo: config.relativeTo, 353 | usePx: true 354 | }), 355 | } 356 | }).filter((size): size is UtopiaSize => !!size) 357 | } 358 | 359 | export const calculateSpaceScale = (config: UtopiaSpaceConfig): UtopiaSpaceScale => { 360 | const positiveSteps = [...config.positiveSteps || []].sort(sortNumberAscending) 361 | .map((multiplier, i) => calculateSpaceSize(config, multiplier, i + 1)).reverse(); 362 | 363 | const negativeSteps = [...config.negativeSteps || []].sort(sortNumberAscending).reverse() 364 | .map((multiplier, i) => calculateSpaceSize(config, multiplier, -1 * (i + 1))); 365 | 366 | const sizes = [ 367 | ...positiveSteps, 368 | calculateSpaceSize(config, 1, 0), 369 | ...negativeSteps 370 | ]; 371 | 372 | const oneUpPairs = calculateOneUpPairs(config, sizes); 373 | const customPairs = calculateCustomPairs(config, sizes); 374 | 375 | return { 376 | sizes, 377 | oneUpPairs, 378 | customPairs 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from 'vitest'; 2 | import { calculateClamp, calculateClamps, calculateSpaceScale, calculateTypeScale } from '.'; 3 | 4 | // utils 5 | const logObject = (a: unknown) => console.dir(a, { depth: 4 }); 6 | 7 | describe('calculateClamp', () => { 8 | 9 | test('should generate a single clamp function', () => { 10 | const result = calculateClamp({ minSize: 16, maxSize: 32, minWidth: 320, maxWidth: 1240 }); 11 | const expected = 'clamp(1rem, 0.6522rem + 1.7391vw, 2rem)'; 12 | expect(result).toEqual(expected); 13 | }); 14 | 15 | test('should generate a px clamp function', () => { 16 | const result = calculateClamp({ minSize: 16, maxSize: 32, minWidth: 320, maxWidth: 1240, usePx: true }); 17 | const expected = 'clamp(16px, 10.4348px + 1.7391vw, 32px)'; 18 | expect(result).toEqual(expected); 19 | }); 20 | 21 | test('should generate a cqi clamp function', () => { 22 | const result = calculateClamp({ minSize: 16, maxSize: 32, minWidth: 320, maxWidth: 1240, relativeTo: 'container' }); 23 | const expected = 'clamp(1rem, 0.6522rem + 1.7391cqi, 2rem)'; 24 | expect(result).toEqual(expected); 25 | }); 26 | 27 | 28 | test('should generate a vi clamp function', () => { 29 | const result = calculateClamp({ minSize: 16, maxSize: 32, minWidth: 320, maxWidth: 1240, relativeTo: 'viewport' }); 30 | const expected = 'clamp(1rem, 0.6522rem + 1.7391vi, 2rem)'; 31 | expect(result).toEqual(expected); 32 | }); 33 | 34 | }); 35 | 36 | describe('calculateClamps', () => { 37 | 38 | test('should generate multiple clamps', () => { 39 | const result = calculateClamps({ minWidth: 320, maxWidth: 1080, pairs: [[12, 16], [40, 28]] }); 40 | const expected = [ 41 | { 42 | clamp: "clamp(0.75rem, 0.6447rem + 0.5263vw, 1rem)", 43 | clampPx: "clamp(12px, 10.3158px + 0.5263vw, 16px)", 44 | label: "12-16", 45 | }, 46 | { 47 | clamp: "clamp(1.75rem, 2.8158rem + -1.5789vw, 2.5rem)", 48 | clampPx: "clamp(28px, 45.0526px + -1.5789vw, 40px)", 49 | label: "40-28", 50 | }, 51 | ]; 52 | expect(result).toStrictEqual(expected); 53 | }); 54 | 55 | }); 56 | 57 | describe('calculateSpaceScale', () => { 58 | 59 | test('should generate a valid scale', () => { 60 | const result = calculateSpaceScale({ 61 | minWidth: 320, 62 | maxWidth: 1240, 63 | minSize: 18, 64 | maxSize: 20, 65 | positiveSteps: [1.5, 2, 3, 4, 6], 66 | negativeSteps: [0.75, 0.5, 0.25], 67 | customSizes: ['s-l', '2xl-4xl'] 68 | }); 69 | const expected = { 70 | sizes: [ 71 | { 72 | label: '3xs', 73 | minSize: 5, 74 | maxSize: 5, 75 | clamp: 'clamp(0.3125rem, 0.3125rem + 0vw, 0.3125rem)', 76 | clampPx: 'clamp(5px, 5px + 0vw, 5px)', 77 | multiplier: 0.25 78 | }, 79 | { 80 | label: '2xs', 81 | minSize: 9, 82 | maxSize: 10, 83 | clamp: 'clamp(0.5625rem, 0.5408rem + 0.1087vw, 0.625rem)', 84 | clampPx: 'clamp(9px, 8.6522px + 0.1087vw, 10px)', 85 | multiplier: 0.5 86 | }, 87 | { 88 | label: 'xs', 89 | minSize: 14, 90 | maxSize: 15, 91 | clamp: 'clamp(0.875rem, 0.8533rem + 0.1087vw, 0.9375rem)', 92 | clampPx: 'clamp(14px, 13.6522px + 0.1087vw, 15px)', 93 | multiplier: 0.75 94 | }, 95 | { 96 | label: 's', 97 | minSize: 18, 98 | maxSize: 20, 99 | clamp: 'clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem)', 100 | clampPx: 'clamp(18px, 17.3043px + 0.2174vw, 20px)', 101 | multiplier: 1, 102 | }, 103 | { 104 | label: 'm', 105 | minSize: 27, 106 | maxSize: 30, 107 | clamp: 'clamp(1.6875rem, 1.6223rem + 0.3261vw, 1.875rem)', 108 | clampPx: 'clamp(27px, 25.9565px + 0.3261vw, 30px)', 109 | multiplier: 1.5 110 | }, 111 | { 112 | label: 'l', 113 | minSize: 36, 114 | maxSize: 40, 115 | clamp: 'clamp(2.25rem, 2.163rem + 0.4348vw, 2.5rem)', 116 | clampPx: 'clamp(36px, 34.6087px + 0.4348vw, 40px)', 117 | multiplier: 2 118 | }, 119 | { 120 | label: 'xl', 121 | minSize: 54, 122 | maxSize: 60, 123 | clamp: 'clamp(3.375rem, 3.2446rem + 0.6522vw, 3.75rem)', 124 | clampPx: 'clamp(54px, 51.913px + 0.6522vw, 60px)', 125 | multiplier: 3 126 | }, 127 | { 128 | label: '2xl', 129 | minSize: 72, 130 | maxSize: 80, 131 | clamp: 'clamp(4.5rem, 4.3261rem + 0.8696vw, 5rem)', 132 | clampPx: 'clamp(72px, 69.2174px + 0.8696vw, 80px)', 133 | multiplier: 4 134 | }, 135 | { 136 | label: '3xl', 137 | minSize: 108, 138 | maxSize: 120, 139 | clamp: 'clamp(6.75rem, 6.4891rem + 1.3043vw, 7.5rem)', 140 | clampPx: 'clamp(108px, 103.8261px + 1.3043vw, 120px)', 141 | multiplier: 6 142 | } 143 | ], 144 | oneUpPairs: [ 145 | { 146 | label: '3xs-2xs', 147 | minSize: 5, 148 | maxSize: 10, 149 | clamp: 'clamp(0.3125rem, 0.2038rem + 0.5435vw, 0.625rem)', 150 | clampPx: 'clamp(5px, 3.2609px + 0.5435vw, 10px)' 151 | }, 152 | { 153 | label: '2xs-xs', 154 | minSize: 9, 155 | maxSize: 15, 156 | clamp: 'clamp(0.5625rem, 0.4321rem + 0.6522vw, 0.9375rem)', 157 | clampPx: 'clamp(9px, 6.913px + 0.6522vw, 15px)' 158 | }, 159 | { 160 | label: 'xs-s', 161 | minSize: 14, 162 | maxSize: 20, 163 | clamp: 'clamp(0.875rem, 0.7446rem + 0.6522vw, 1.25rem)', 164 | clampPx: 'clamp(14px, 11.913px + 0.6522vw, 20px)' 165 | }, 166 | { 167 | label: 's-m', 168 | minSize: 18, 169 | maxSize: 30, 170 | clamp: 'clamp(1.125rem, 0.8641rem + 1.3043vw, 1.875rem)', 171 | clampPx: 'clamp(18px, 13.8261px + 1.3043vw, 30px)' 172 | }, 173 | { 174 | label: 'm-l', 175 | minSize: 27, 176 | maxSize: 40, 177 | clamp: 'clamp(1.6875rem, 1.4049rem + 1.413vw, 2.5rem)', 178 | clampPx: 'clamp(27px, 22.4783px + 1.413vw, 40px)' 179 | }, 180 | { 181 | label: 'l-xl', 182 | minSize: 36, 183 | maxSize: 60, 184 | clamp: 'clamp(2.25rem, 1.7283rem + 2.6087vw, 3.75rem)', 185 | clampPx: 'clamp(36px, 27.6522px + 2.6087vw, 60px)' 186 | }, 187 | { 188 | label: 'xl-2xl', 189 | minSize: 54, 190 | maxSize: 80, 191 | clamp: 'clamp(3.375rem, 2.8098rem + 2.8261vw, 5rem)', 192 | clampPx: 'clamp(54px, 44.9565px + 2.8261vw, 80px)' 193 | }, 194 | { 195 | label: '2xl-3xl', 196 | minSize: 72, 197 | maxSize: 120, 198 | clamp: 'clamp(4.5rem, 3.4565rem + 5.2174vw, 7.5rem)', 199 | clampPx: 'clamp(72px, 55.3043px + 5.2174vw, 120px)' 200 | } 201 | ], 202 | customPairs: [ 203 | { 204 | label: 's-l', 205 | minSize: 18, 206 | maxSize: 40, 207 | clamp: 'clamp(1.125rem, 0.6467rem + 2.3913vw, 2.5rem)', 208 | clampPx: 'clamp(18px, 10.3478px + 2.3913vw, 40px)' 209 | } 210 | ] 211 | }; 212 | expect(result).toStrictEqual(expected); 213 | }); 214 | 215 | test('should generate a valid scale', () => { 216 | const result = calculateSpaceScale({ 217 | minWidth: 320, 218 | maxWidth: 1240, 219 | minSize: 18, 220 | maxSize: 20, 221 | positiveSteps: [1.5, 2, 3, 4, 6, 8, 10], 222 | negativeSteps: [0.75, 0.5, 0.25], 223 | customSizes: ['s-l', '2xl-4xl'] 224 | }); 225 | const expected = { 226 | sizes: [ 227 | { 228 | label: '3xs', 229 | minSize: 5, 230 | maxSize: 5, 231 | clamp: 'clamp(0.3125rem, 0.3125rem + 0vw, 0.3125rem)', 232 | clampPx: 'clamp(5px, 5px + 0vw, 5px)', 233 | multiplier: 0.25 234 | }, 235 | { 236 | label: '2xs', 237 | minSize: 9, 238 | maxSize: 10, 239 | clamp: 'clamp(0.5625rem, 0.5408rem + 0.1087vw, 0.625rem)', 240 | clampPx: 'clamp(9px, 8.6522px + 0.1087vw, 10px)', 241 | multiplier: 0.5 242 | }, 243 | { 244 | label: 'xs', 245 | minSize: 14, 246 | maxSize: 15, 247 | clamp: 'clamp(0.875rem, 0.8533rem + 0.1087vw, 0.9375rem)', 248 | clampPx: 'clamp(14px, 13.6522px + 0.1087vw, 15px)', 249 | multiplier: 0.75 250 | }, 251 | { 252 | label: 's', 253 | minSize: 18, 254 | maxSize: 20, 255 | clamp: 'clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem)', 256 | clampPx: 'clamp(18px, 17.3043px + 0.2174vw, 20px)', 257 | multiplier: 1 258 | }, 259 | { 260 | label: 'm', 261 | minSize: 27, 262 | maxSize: 30, 263 | clamp: 'clamp(1.6875rem, 1.6223rem + 0.3261vw, 1.875rem)', 264 | clampPx: 'clamp(27px, 25.9565px + 0.3261vw, 30px)', 265 | multiplier: 1.5 266 | }, 267 | { 268 | label: 'l', 269 | minSize: 36, 270 | maxSize: 40, 271 | clamp: 'clamp(2.25rem, 2.163rem + 0.4348vw, 2.5rem)', 272 | clampPx: 'clamp(36px, 34.6087px + 0.4348vw, 40px)', 273 | multiplier: 2 274 | }, 275 | { 276 | label: 'xl', 277 | minSize: 54, 278 | maxSize: 60, 279 | clamp: 'clamp(3.375rem, 3.2446rem + 0.6522vw, 3.75rem)', 280 | clampPx: 'clamp(54px, 51.913px + 0.6522vw, 60px)', 281 | multiplier: 3 282 | }, 283 | { 284 | label: '2xl', 285 | minSize: 72, 286 | maxSize: 80, 287 | clamp: 'clamp(4.5rem, 4.3261rem + 0.8696vw, 5rem)', 288 | clampPx: 'clamp(72px, 69.2174px + 0.8696vw, 80px)', 289 | multiplier: 4 290 | }, 291 | { 292 | label: '3xl', 293 | minSize: 108, 294 | maxSize: 120, 295 | clamp: 'clamp(6.75rem, 6.4891rem + 1.3043vw, 7.5rem)', 296 | clampPx: 'clamp(108px, 103.8261px + 1.3043vw, 120px)', 297 | multiplier: 6 298 | }, 299 | { 300 | label: '4xl', 301 | minSize: 144, 302 | maxSize: 160, 303 | clamp: 'clamp(9rem, 8.6522rem + 1.7391vw, 10rem)', 304 | clampPx: 'clamp(144px, 138.4348px + 1.7391vw, 160px)', 305 | multiplier: 8 306 | }, 307 | { 308 | label: '5xl', 309 | minSize: 180, 310 | maxSize: 200, 311 | clamp: 'clamp(11.25rem, 10.8152rem + 2.1739vw, 12.5rem)', 312 | clampPx: 'clamp(180px, 173.0435px + 2.1739vw, 200px)', 313 | multiplier: 10 314 | } 315 | ], 316 | oneUpPairs: [ 317 | { 318 | label: '3xs-2xs', 319 | minSize: 5, 320 | maxSize: 10, 321 | clamp: 'clamp(0.3125rem, 0.2038rem + 0.5435vw, 0.625rem)', 322 | clampPx: 'clamp(5px, 3.2609px + 0.5435vw, 10px)' 323 | }, 324 | { 325 | label: '2xs-xs', 326 | minSize: 9, 327 | maxSize: 15, 328 | clamp: 'clamp(0.5625rem, 0.4321rem + 0.6522vw, 0.9375rem)', 329 | clampPx: 'clamp(9px, 6.913px + 0.6522vw, 15px)' 330 | }, 331 | { 332 | label: 'xs-s', 333 | minSize: 14, 334 | maxSize: 20, 335 | clamp: 'clamp(0.875rem, 0.7446rem + 0.6522vw, 1.25rem)', 336 | clampPx: 'clamp(14px, 11.913px + 0.6522vw, 20px)' 337 | }, 338 | { 339 | label: 's-m', 340 | minSize: 18, 341 | maxSize: 30, 342 | clamp: 'clamp(1.125rem, 0.8641rem + 1.3043vw, 1.875rem)', 343 | clampPx: 'clamp(18px, 13.8261px + 1.3043vw, 30px)' 344 | }, 345 | { 346 | label: 'm-l', 347 | minSize: 27, 348 | maxSize: 40, 349 | clamp: 'clamp(1.6875rem, 1.4049rem + 1.413vw, 2.5rem)', 350 | clampPx: 'clamp(27px, 22.4783px + 1.413vw, 40px)' 351 | }, 352 | { 353 | label: 'l-xl', 354 | minSize: 36, 355 | maxSize: 60, 356 | clamp: 'clamp(2.25rem, 1.7283rem + 2.6087vw, 3.75rem)', 357 | clampPx: 'clamp(36px, 27.6522px + 2.6087vw, 60px)' 358 | }, 359 | { 360 | label: 'xl-2xl', 361 | minSize: 54, 362 | maxSize: 80, 363 | clamp: 'clamp(3.375rem, 2.8098rem + 2.8261vw, 5rem)', 364 | clampPx: 'clamp(54px, 44.9565px + 2.8261vw, 80px)' 365 | }, 366 | { 367 | label: '2xl-3xl', 368 | minSize: 72, 369 | maxSize: 120, 370 | clamp: 'clamp(4.5rem, 3.4565rem + 5.2174vw, 7.5rem)', 371 | clampPx: 'clamp(72px, 55.3043px + 5.2174vw, 120px)' 372 | }, 373 | { 374 | label: '3xl-4xl', 375 | minSize: 108, 376 | maxSize: 160, 377 | clamp: 'clamp(6.75rem, 5.6196rem + 5.6522vw, 10rem)', 378 | clampPx: 'clamp(108px, 89.913px + 5.6522vw, 160px)' 379 | }, 380 | { 381 | label: '4xl-5xl', 382 | minSize: 144, 383 | maxSize: 200, 384 | clamp: 'clamp(9rem, 7.7826rem + 6.087vw, 12.5rem)', 385 | clampPx: 'clamp(144px, 124.5217px + 6.087vw, 200px)' 386 | } 387 | ], 388 | customPairs: [ 389 | { 390 | label: 's-l', 391 | minSize: 18, 392 | maxSize: 40, 393 | clamp: 'clamp(1.125rem, 0.6467rem + 2.3913vw, 2.5rem)', 394 | clampPx: 'clamp(18px, 10.3478px + 2.3913vw, 40px)' 395 | }, 396 | { 397 | label: '2xl-4xl', 398 | minSize: 72, 399 | maxSize: 160, 400 | clamp: 'clamp(4.5rem, 2.587rem + 9.5652vw, 10rem)', 401 | clampPx: 'clamp(72px, 41.3913px + 9.5652vw, 160px)' 402 | } 403 | ] 404 | }; 405 | expect(result).toStrictEqual(expected); 406 | }); 407 | 408 | }); 409 | 410 | describe('calculateTypeScale', () => { 411 | 412 | test('should generate a valid scale', () => { 413 | const result = calculateTypeScale({ 414 | minWidth: 320, 415 | maxWidth: 1240, 416 | minFontSize: 18, 417 | maxFontSize: 20, 418 | minTypeScale: 1.2, 419 | maxTypeScale: 1.25, 420 | positiveSteps: 5, 421 | negativeSteps: 2 422 | }); 423 | const expected = [ 424 | { 425 | step: 5, 426 | label: '5', 427 | minFontSize: 44.7898, 428 | maxFontSize: 61.0352, 429 | wcagViolation: null, 430 | clamp: 'clamp(2.7994rem, 2.4462rem + 1.7658vw, 3.8147rem)' 431 | }, 432 | { 433 | step: 4, 434 | label: '4', 435 | minFontSize: 37.3248, 436 | maxFontSize: 48.8281, 437 | wcagViolation: null, 438 | clamp: 'clamp(2.3328rem, 2.0827rem + 1.2504vw, 3.0518rem)' 439 | }, 440 | { 441 | step: 3, 442 | label: '3', 443 | minFontSize: 31.104, 444 | maxFontSize: 39.0625, 445 | wcagViolation: null, 446 | clamp: 'clamp(1.944rem, 1.771rem + 0.8651vw, 2.4414rem)' 447 | }, 448 | { 449 | step: 2, 450 | label: '2', 451 | minFontSize: 25.92, 452 | maxFontSize: 31.25, 453 | wcagViolation: null, 454 | clamp: 'clamp(1.62rem, 1.5041rem + 0.5793vw, 1.9531rem)' 455 | }, 456 | { 457 | step: 1, 458 | label: '1', 459 | minFontSize: 21.6, 460 | maxFontSize: 25, 461 | wcagViolation: null, 462 | clamp: 'clamp(1.35rem, 1.2761rem + 0.3696vw, 1.5625rem)' 463 | }, 464 | { 465 | step: 0, 466 | label: '0', 467 | minFontSize: 18, 468 | maxFontSize: 20, 469 | wcagViolation: null, 470 | clamp: 'clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem)' 471 | }, 472 | { 473 | step: -1, 474 | label: '-1', 475 | minFontSize: 15, 476 | maxFontSize: 16, 477 | wcagViolation: null, 478 | clamp: 'clamp(0.9375rem, 0.9158rem + 0.1087vw, 1rem)' 479 | }, 480 | { 481 | step: -2, 482 | label: '-2', 483 | minFontSize: 12.5, 484 | maxFontSize: 12.8, 485 | wcagViolation: null, 486 | clamp: 'clamp(0.7813rem, 0.7747rem + 0.0326vw, 0.8rem)' 487 | } 488 | ]; 489 | expect(result).toStrictEqual(expected); 490 | }); 491 | 492 | test('should generate a scale with tailwind labels', () => { 493 | const result = calculateTypeScale({ 494 | minWidth: 320, 495 | maxWidth: 1240, 496 | minFontSize: 18, 497 | maxFontSize: 20, 498 | minTypeScale: 1.2, 499 | maxTypeScale: 1.25, 500 | positiveSteps: 5, 501 | negativeSteps: 3, 502 | labelStyle: 'tailwind' 503 | }); 504 | 505 | const expected = [ 506 | { 507 | step: 5, 508 | label: '4xl', 509 | minFontSize: 44.7898, 510 | maxFontSize: 61.0352, 511 | wcagViolation: null, 512 | clamp: 'clamp(2.7994rem, 2.4462rem + 1.7658vw, 3.8147rem)' 513 | }, 514 | { 515 | step: 4, 516 | label: '3xl', 517 | minFontSize: 37.3248, 518 | maxFontSize: 48.8281, 519 | wcagViolation: null, 520 | clamp: 'clamp(2.3328rem, 2.0827rem + 1.2504vw, 3.0518rem)' 521 | }, 522 | { 523 | step: 3, 524 | label: '2xl', 525 | minFontSize: 31.104, 526 | maxFontSize: 39.0625, 527 | wcagViolation: null, 528 | clamp: 'clamp(1.944rem, 1.771rem + 0.8651vw, 2.4414rem)' 529 | }, 530 | { 531 | step: 2, 532 | label: 'xl', 533 | minFontSize: 25.92, 534 | maxFontSize: 31.25, 535 | wcagViolation: null, 536 | clamp: 'clamp(1.62rem, 1.5041rem + 0.5793vw, 1.9531rem)' 537 | }, 538 | { 539 | step: 1, 540 | label: 'lg', 541 | minFontSize: 21.6, 542 | maxFontSize: 25, 543 | wcagViolation: null, 544 | clamp: 'clamp(1.35rem, 1.2761rem + 0.3696vw, 1.5625rem)' 545 | }, 546 | { 547 | step: 0, 548 | label: 'base', 549 | minFontSize: 18, 550 | maxFontSize: 20, 551 | wcagViolation: null, 552 | clamp: 'clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem)' 553 | }, 554 | { 555 | step: -1, 556 | label: 'sm', 557 | minFontSize: 15, 558 | maxFontSize: 16, 559 | wcagViolation: null, 560 | clamp: 'clamp(0.9375rem, 0.9158rem + 0.1087vw, 1rem)' 561 | }, 562 | { 563 | step: -2, 564 | label: 'xs', 565 | minFontSize: 12.5, 566 | maxFontSize: 12.8, 567 | wcagViolation: null, 568 | clamp: 'clamp(0.7813rem, 0.7747rem + 0.0326vw, 0.8rem)' 569 | }, 570 | { 571 | step: -3, 572 | label: '2xs', 573 | minFontSize: 10.4167, 574 | maxFontSize: 10.24, 575 | wcagViolation: null, 576 | clamp: 'clamp(0.64rem, 0.6549rem + -0.0192vw, 0.651rem)' 577 | } 578 | ]; 579 | expect(result).toStrictEqual(expected); 580 | }); 581 | 582 | test('should generate a scale with tshirt labels', () => { 583 | const result = calculateTypeScale({ 584 | minWidth: 320, 585 | maxWidth: 1240, 586 | minFontSize: 18, 587 | maxFontSize: 20, 588 | minTypeScale: 1.2, 589 | maxTypeScale: 1.25, 590 | positiveSteps: 5, 591 | negativeSteps: 3, 592 | labelStyle: 'tshirt' 593 | }); 594 | 595 | const expected = [ 596 | { 597 | step: 5, 598 | label: '4xl', 599 | minFontSize: 44.7898, 600 | maxFontSize: 61.0352, 601 | wcagViolation: null, 602 | clamp: 'clamp(2.7994rem, 2.4462rem + 1.7658vw, 3.8147rem)' 603 | }, 604 | { 605 | step: 4, 606 | label: '3xl', 607 | minFontSize: 37.3248, 608 | maxFontSize: 48.8281, 609 | wcagViolation: null, 610 | clamp: 'clamp(2.3328rem, 2.0827rem + 1.2504vw, 3.0518rem)' 611 | }, 612 | { 613 | step: 3, 614 | label: '2xl', 615 | minFontSize: 31.104, 616 | maxFontSize: 39.0625, 617 | wcagViolation: null, 618 | clamp: 'clamp(1.944rem, 1.771rem + 0.8651vw, 2.4414rem)' 619 | }, 620 | { 621 | step: 2, 622 | label: 'xl', 623 | minFontSize: 25.92, 624 | maxFontSize: 31.25, 625 | wcagViolation: null, 626 | clamp: 'clamp(1.62rem, 1.5041rem + 0.5793vw, 1.9531rem)' 627 | }, 628 | { 629 | step: 1, 630 | label: 'l', 631 | minFontSize: 21.6, 632 | maxFontSize: 25, 633 | wcagViolation: null, 634 | clamp: 'clamp(1.35rem, 1.2761rem + 0.3696vw, 1.5625rem)' 635 | }, 636 | { 637 | step: 0, 638 | label: 'm', 639 | minFontSize: 18, 640 | maxFontSize: 20, 641 | wcagViolation: null, 642 | clamp: 'clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem)' 643 | }, 644 | { 645 | step: -1, 646 | label: 's', 647 | minFontSize: 15, 648 | maxFontSize: 16, 649 | wcagViolation: null, 650 | clamp: 'clamp(0.9375rem, 0.9158rem + 0.1087vw, 1rem)' 651 | }, 652 | { 653 | step: -2, 654 | label: 'xs', 655 | minFontSize: 12.5, 656 | maxFontSize: 12.8, 657 | wcagViolation: null, 658 | clamp: 'clamp(0.7813rem, 0.7747rem + 0.0326vw, 0.8rem)' 659 | }, 660 | { 661 | step: -3, 662 | label: '2xs', 663 | minFontSize: 10.4167, 664 | maxFontSize: 10.24, 665 | wcagViolation: null, 666 | clamp: 'clamp(0.64rem, 0.6549rem + -0.0192vw, 0.651rem)' 667 | } 668 | ]; 669 | expect(result).toStrictEqual(expected); 670 | }); 671 | 672 | }); 673 | --------------------------------------------------------------------------------