Skip to content

Commit

Permalink
Fix lint-a11y bronze support
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Mar 15, 2024
1 parent fa36107 commit cc11e0a
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 109 deletions.
5 changes: 5 additions & 0 deletions .changeset/dry-cats-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cobalt-ui/lint-a11y": patch
---

Fix issue with bodyText calculation
5 changes: 5 additions & 0 deletions .changeset/selfish-coats-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cobalt-ui/lint-a11y": patch
---

Fix silver <> bronze confusion
10 changes: 9 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"printWidth": 180,
"singleQuote": true,
"trailingComma": "all"
"trailingComma": "all",
"overrides": [
{
"files": ["*.md"],
"options": {
"singleQuote": false
}
}
]
}
147 changes: 80 additions & 67 deletions docs/integrations/a11y.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions packages/lint-a11y/src/apca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export const APCA_FONT_LOOKUP_TABLE: [number, [number, number, number, number, n
* 777: non-text
* 999: unacceptable
*/
export function getMinimumLc(fontSize: string | number, fontWeight: number, bodyText = false): number {
export function getMinimumSilverLc(fontSize: string | number, fontWeight: number, bodyText = true): number {
if (!(fontWeight > 0 && fontWeight < 1000)) {
throw new Error(`Invalid font weight: ${fontWeight}`);
}
Expand All @@ -207,5 +207,7 @@ export function getMinimumLc(fontSize: string | number, fontWeight: number, body
// with baseLc calculated, we may need to interpolate between font sizes; do so with simple linear interpolation
const stepUpLc = sizeDelta > 0 ? APCA_FONT_LOOKUP_TABLE[sizeRowI - 1]![1][weightColI]! : baseLc;
const finalLc = sizeDelta > 0 ? baseLc + sizeDelta * (stepUpLc - baseLc) : baseLc;
return bodyText ? finalLc + 15 : finalLc;

// note; the “add 15 Lc to body text” requirement is hard
return finalLc + (bodyText && finalLc <= 70 ? 15 : 0);
}
32 changes: 20 additions & 12 deletions packages/lint-a11y/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type Plugin, type ParsedToken, type LintNotice, type ParsedColorToken,
import { blend, modeRgb, modeHsl, modeHsv, modeP3, modeOkhsl, modeOklch, modeOklab, modeXyz50, modeXyz65, modeLrgb, useMode, wcagContrast } from 'culori/fn';
import { APCAcontrast } from 'apca-w3';
import { isWCAG2LargeText, round } from './lib.js';
import { getMinimumLc } from './apca.js';
import { getMinimumSilverLc } from './apca.js';

// register colorspaces for Culori to parse (these are side-effect-y)
useMode(modeHsl);
Expand Down Expand Up @@ -45,7 +45,7 @@ export interface RuleContrastCheck {
/** Enforce WCAG 2 contrast checking? (default: 'AA') */
wcag2?: 'AA' | 'AAA' | number | false;
/** Enforce APCA contrast checking? (default: false) @see https://www.myndex.com/APCA/ */
apca?: 'bronze' | 'bronze-body' | number | false;
apca?: 'silver' | 'silver-nonbody' | number | false;
}

function evaluateContrast(tokens: ParsedToken[], options: RuleContrastOptions): LintNotice[] {
Expand Down Expand Up @@ -92,18 +92,26 @@ function evaluateContrast(tokens: ParsedToken[], options: RuleContrastOptions):
const minContrast = typeof wcag2 === 'string' ? WCAG2_MIN_CONTRAST[wcag2][isLargeText ? 'large' : 'default'] : wcag2;
const defaultResult = wcagContrast(fg, bg);
if (defaultResult < minContrast) {
const modeText = mode === '.' ? '' : ` (mode: ${mode})`;
const levelText = typeof wcag2 === 'string' ? ` ("${wcag2}")` : '';
notices.push({
id: RULES.contrast,
message: `WCAG 2: Token pair ${fg}, ${bg}${mode === '.' ? '' : ` (mode: ${mode})`} failed contrast. Expected ${minContrast}:1, received ${round(defaultResult)}:1`,
message: `WCAG 2: Token pair ${fg}, ${bg}${modeText} failed contrast. Expected ${minContrast}:1${levelText}, received ${round(defaultResult)}:1`,
});
}
}
}

// APCA
if (typeof apca === 'string' || (typeof apca === 'number' && Math.abs(apca) > 0)) {
if (typeof apca === 'string' && apca !== 'bronze' && apca !== 'bronze-body') {
throw new Error(`APCA: expected value \`'bronze'\` or \`'bronze-body'\`, received ${apca}`);
if ((apca as string) === 'gold') {
throw new Error(`APCA: "gold" not implemented; specify "silver", "silver-nonbody", Lc \`number\`, or \`false\`.`);
}
if ((apca as string) === 'bronze') {
throw new Error(`APCA: "bronze" not supported; specify an Lc \`number\` manually.`);
}
if (typeof apca === 'string' && apca !== 'silver' && apca !== 'silver-nonbody') {
throw new Error(`APCA: expected value "silver" or "silver-nonbody", received "${apca}"`);
}

const testSets: {
Expand Down Expand Up @@ -133,20 +141,20 @@ function evaluateContrast(tokens: ParsedToken[], options: RuleContrastOptions):
}

for (const { fgY, fgRaw, bgY, bgRaw, mode, fontSize, fontWeight } of testSets) {
if ((apca === 'silver' || apca === 'silver-nonbody') && (!fontSize || !fontWeight)) {
throw new Error(`APCA: "${apca}" compliance requires \`typography\` token. Use manual number if omitted.`);
}
const lc = APCAcontrast(fgY, bgY);
if (typeof lc === 'string') {
throw new Error(`Internal error: expected number, APCA returned "${lc}"`); // types are wrong?
}
let minContrast = 60;
if (typeof apca === 'number') {
minContrast = apca;
} else if (fontSize && fontWeight) {
minContrast = getMinimumLc(fontSize, fontWeight, apca === 'bronze-body');
}
const minContrast = typeof apca === 'number' ? apca : getMinimumSilverLc(fontSize!, fontWeight!, apca === 'silver');
if (Math.abs(lc) < minContrast) {
const modeText = mode === '.' ? '' : ` (mode: ${mode})`;
const levelText = typeof apca === 'string' ? ` ("${apca}")` : '';
notices.push({
id: RULES.contrast,
message: `APCA: Token pair ${fgRaw}, ${bgRaw}${mode === '.' ? '' : ` (mode: ${mode})`} failed contrast. Expected ${minContrast}, received ${round(Math.abs(lc))}.`,
message: `APCA: Token pair ${fgRaw}, ${bgRaw}${modeText} failed contrast. Expected ${minContrast}${levelText}, received ${round(Math.abs(lc))}`,
});
}
}
Expand Down
29 changes: 18 additions & 11 deletions packages/lint-a11y/test/apca.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { expect, test } from 'vitest';
import { getMinimumLc } from '../src/apca.js';
import { describe, expect, test } from 'vitest';
import { getMinimumSilverLc } from '../src/apca.js';

test('getMinimumLc', () => {
expect(getMinimumLc(16, 400)).toBe(90);
expect(getMinimumLc('1rem', 400, true)).toBe(105);
expect(getMinimumLc('18px', 700)).toBe(55);
expect(getMinimumLc(18, 300)).toBe(100);
expect(getMinimumLc(21, 300)).toBe(90);
expect(getMinimumLc('19.5px', 300)).toBe(95);
expect(getMinimumLc(28, 200)).toBe(100);
expect(getMinimumLc('96px', 900)).toBe(30);
describe('getMinimumSilverLc', () => {
const tests: [string, { given: Parameters<typeof getMinimumSilverLc>; want: ReturnType<typeof getMinimumSilverLc> }][] = [
['14px/400', { given: [14, 400, true], want: 100 }],
['16px/400', { given: [16, 400, true], want: 90 }],
['1rem/400', { given: ['1rem', 400, true], want: 90 }],
['18px/700', { given: ['18px', 700, false], want: 55 }],
['18px/300', { given: [18, 300, false], want: 100 }],
['21px/300', { given: [21, 300, false], want: 90 }],
['19.5px/300', { given: ['19.5px', 300, false], want: 95 }],
['28px/200', { given: [28, 200, false], want: 100 }],
['96px/900', { given: ['96px', 900, false], want: 30 }],
];

test.each(tests)('%s', (_, { given, want }) => {
expect(getMinimumSilverLc(...given)).toBe(want);
});
});
2 changes: 1 addition & 1 deletion packages/lint-a11y/test/fixtures/tokens.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default {
typography: 'typography.body',
},
wcag2: 'AAA',
apca: 'bronze',
apca: 'silver',
},
],
},
Expand Down
54 changes: 39 additions & 15 deletions packages/lint-a11y/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import build from '@cobalt-ui/cli/dist/build.js';
import { type ParseResult } from '@cobalt-ui/core';
import fs from 'node:fs';
import { describe, expect, test } from 'vitest';
import { execa } from 'execa';
Expand All @@ -20,7 +21,26 @@ describe('a11y plugin', () => {
},
][] = [
[
'passing',
'passing (silver)',
{
options: {
checks: [
{
tokens: {
foreground: 'color.high-contrast-text',
background: 'color.high-contrast-bg',
typography: 'typography.large',
},
apca: 'silver',
wcag2: 'AAA',
},
],
},
want: { success: true },
},
],
[
'passing (number)',
{
options: {
checks: [
Expand Down Expand Up @@ -58,8 +78,8 @@ describe('a11y plugin', () => {
},
want: {
errors: [
'[@cobalt-ui/lint-a11y] Error a11y/contrast: WCAG 2: Token pair #606060, #101010 (mode: dark) failed contrast. Expected 7:1, received 3.03:1',
'[@cobalt-ui/lint-a11y] Error a11y/contrast: APCA: Token pair #606060, #101010 (mode: dark) failed contrast. Expected 75, received 22.38.',
'[@cobalt-ui/lint-a11y] Error a11y/contrast: WCAG 2: Token pair #606060, #101010 (mode: dark) failed contrast. Expected 7:1 ("AAA"), received 3.03:1',
'[@cobalt-ui/lint-a11y] Error a11y/contrast: APCA: Token pair #606060, #101010 (mode: dark) failed contrast. Expected 75, received 22.38',
],
},
},
Expand All @@ -68,13 +88,14 @@ describe('a11y plugin', () => {
'no options provided',
{
options: undefined,
want: { errors: ['Error: options.checks must be an array'] },
want: { errors: ['options.checks must be an array'] },
},
],
];
test.each(tests)('%s', async (name, { options, want }) => {
let buildResult: ParseResult;
try {
const buildResult = await build(tokens, {
buildResult = await build(tokens, {
tokens: [tokensURL],
outDir: new URL('./index/', import.meta.url),
plugins: [a11y()],
Expand All @@ -89,18 +110,21 @@ describe('a11y plugin', () => {
},
color: {},
});
} catch (err) {
expect(err.message).toBe(want.errors?.[0]);
return;
}

if (want.success) {
if (buildResult.errors) {
// eslint-disable-next-line no-console
console.error(...buildResult.errors);
}
expect(buildResult.errors?.[0]).toBeUndefined();
} else {
expect(buildResult.errors).toEqual(want.errors);
if (want.success) {
if (buildResult.errors) {
// eslint-disable-next-line no-console
console.error(...buildResult.errors);
}
expect(buildResult.errors?.[0]).toBeUndefined();
} else {
for (const i in buildResult.errors) {
expect(buildResult.errors[i]).toBe(want.errors![i]);
}
} catch (err) {
expect(String(err)).toBe(want.errors?.[0]);
}
});
});
Expand Down

0 comments on commit cc11e0a

Please sign in to comment.