diff --git a/properties/type-scale.mjs b/properties/type-scale.mjs index c1417df..cf33fd2 100644 --- a/properties/type-scale.mjs +++ b/properties/type-scale.mjs @@ -3,6 +3,22 @@ import { generateTypeScaleProperties, defaultConfig } from '../lib/scales.mjs' export default function typeScaleProperties(state = {}) { const { config = {} } = state const { typeScale = defaultConfig } = config + const validateAccessibility = typeScale.validateAccessibility ?? true + + if (!validateTypeScaleAccessibility(typeScale)) { + let message = 'Your requested type scale fails the WCAG SC 1.4.4 accessibility rule. '; + if (validateAccessibility) { + throw Error(message + + "If you would like to proceed anyway, then set 'validateAccessibility' " + + 'to false in your typeScale config.' + ) + } else { + console.warn(message + + "This is just a warning instead of an error because 'validateAccessibility' " + + 'is set to false.' + ) + } + } let output = '' @@ -14,3 +30,36 @@ export default function typeScaleProperties(state = {}) { return output } + +function validateTypeScaleAccessibility(typeScale) { + // WCAG SC 1.4.4 check, ported from https://github.com/barvian/fluid-tailwind/blob/f85f1ae5a50ec1a37f99418d6d087a3a2e783e1b/packages/fluid-tailwind/src/util/expr.ts#L116 + // @see https://www.smashingmagazine.com/2023/11/addressing-accessibility-concerns-fluid-type/ + const { baseMin, baseMax, viewportMin, viewportMax } = typeScale + const slope = (baseMax - baseMin) / (viewportMax - viewportMin) + const intercept = baseMin - viewportMin * slope + + // 2*zoom1(vw) is the AA requirement + const zoom1 = (vw) => clamp(baseMin, intercept + slope * vw, baseMax) + const zoom5 = (vw) => + // browser doesn't scale vw units when zooming, so this isn't 5*zoom1(vw) + clamp(5 * baseMin, 5 * intercept + slope * vw, 5 * baseMax) + + // Check the clamped points on the lines 2*z1(vw) and zoom5(vw) and fail if zoom5 < 2*zoom1 + if (5 * baseMin < 2 * zoom1(5 * viewportMin)) { + return false + } + if (zoom5(viewportMax) < 2 * baseMax) { + return false + } + return true +} + +/** + * Simulate the math that CSS `clamp` does + * @param {number} min + * @param {number} n + * @param {number} max + */ +function clamp(min, n, max) { + return Math.min(Math.max(n, min), max) +} diff --git a/readme.md b/readme.md index 2a0f281..1007fbd 100644 --- a/readme.md +++ b/readme.md @@ -171,6 +171,7 @@ The configuration file must provide an object as its default export (i.e. `expor | `typeScale.baseMax` | The base font size, in pixels, at the maximum viewport width | number | `18` | | `typeScale.scaleMin` | The ratio, either as a rational number or a named ratio, to use for font size intervals at the minimum viewport width | number or string | `minor-third` | | `typeScale.scaleMax` | The ratio, either as a rational number or a named ratio, to use for font size intervals at the maximum viewport width | number or string | `perfect-fourth` | +| `typeScale.validateAccessibility` | By default, Paramour will throw an error if your type scale would potentially cause a violation of the [WCAG accessibility rule SC 1.4.4](https://www.w3.org/WAI/WCAG22/Understanding/resize-text), which requires that text can be enlarged to twice its original size when zooming. Set this to false to generate such a type scale anyway (a warning message will still appear for awareness). | boolean | `true` | For the space and type scales, the following common ratios can be referenced by name for the `scaleMin` and `scaleMax` properties: diff --git a/test/lib/scales.test.js b/test/lib/scales.test.js index 2162075..1eed74b 100644 --- a/test/lib/scales.test.js +++ b/test/lib/scales.test.js @@ -10,6 +10,7 @@ import { generateTypeScaleProperties, generateSpaceScaleProperties, } from '../../lib/scales.mjs' +import typeScaleProperties from '../../properties/type-scale.mjs' test('getRatioValue', t => { const num = 1.5 @@ -115,3 +116,48 @@ test('generateSpaceScaleProperties', t => { t.ok(isSymmetrical, 'produces a symmetrical set of negative and positive intervals') t.end() }) + +test('validates type scale accessibility', t => { + const commonConfig = { + viewportMin: 320, + viewportMax: 1500, + } + + t.throws(() => { + typeScaleProperties({ + config: { + typeScale: { + ...commonConfig, + baseMin: 7, + baseMax: 18, + } + } + }) + }); + + // should not throw because `validateAccessibility` is set to false + typeScaleProperties({ + config: { + typeScale: { + ...commonConfig, + validateAccessibility: false, + baseMin: 7, + baseMax: 18, + } + } + }) + + // should not throw because it meets the accessibility requirement + typeScaleProperties({ + config: { + typeScale: { + ...commonConfig, + validateAccessibility: false, + baseMin: 8, + baseMax: 18, + } + } + }) + + t.end() +})