Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix lint-a11y bronze support #221

Merged
merged 2 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

86 changes: 45 additions & 41 deletions packages/cli/test/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,62 +3,66 @@ import { describe, expect, it } from 'vitest';

const cmd = '../../../bin/cli.js';

describe('co build', () => {
it('default', async () => {
const cwd = new URL('./fixtures/build-default/', import.meta.url);
await execa('node', [cmd, 'build'], { cwd });
describe(
'co build',
() => {
it('default', async () => {
const cwd = new URL('./fixtures/build-default/', import.meta.url);
await execa('node', [cmd, 'build'], { cwd });

const builtTokens = await import('./fixtures/build-default/tokens/index.js');
const builtTokens = await import('./fixtures/build-default/tokens/index.js');

// rough token count
expect(Object.keys(builtTokens.meta).length).toBe(34);
});
// rough token count
expect(Object.keys(builtTokens.meta).length).toBe(34);
});

it('yaml', async () => {
const cwd = new URL('./fixtures/build-yaml/', import.meta.url);
await execa('node', [cmd, 'build'], { cwd });
it('yaml', async () => {
const cwd = new URL('./fixtures/build-yaml/', import.meta.url);
await execa('node', [cmd, 'build'], { cwd });

const builtTokens = await import('./fixtures/build-yaml/tokens/index.js');
const builtTokens = await import('./fixtures/build-yaml/tokens/index.js');

// rough token count
expect(Object.keys(builtTokens.meta).length).toBe(34);
});
// rough token count
expect(Object.keys(builtTokens.meta).length).toBe(34);
});

it('multiple', async () => {
const cwd = new URL('./fixtures/build-multiple/', import.meta.url);
await execa('node', [cmd, 'build'], { cwd });
it('multiple', async () => {
const cwd = new URL('./fixtures/build-multiple/', import.meta.url);
await execa('node', [cmd, 'build'], { cwd });

const builtTokens = await import('./fixtures/build-multiple/tokens/index.js');
const builtTokens = await import('./fixtures/build-multiple/tokens/index.js');

// rough token count
expect(Object.keys(builtTokens.meta).length).toBe(66);
// rough token count
expect(Object.keys(builtTokens.meta).length).toBe(66);

// test 2: assert "color.black" in "typography.json" took priority
expect(builtTokens.meta['color.black']._original).toEqual({
$value: '#081f2f',
// test 2: assert "color.black" in "typography.json" took priority
expect(builtTokens.meta['color.black']._original).toEqual({
$value: '#081f2f',
});
});
});

describe('config', () => {
it('outDir', async () => {
const cwd = new URL('./fixtures/build-custom-dir/', import.meta.url);
await execa('node', [cmd, 'build'], { cwd });
describe('config', () => {
it('outDir', async () => {
const cwd = new URL('./fixtures/build-custom-dir/', import.meta.url);
await execa('node', [cmd, 'build'], { cwd });

const builtTokens = await import('./fixtures/build-custom-dir/src/tokens/index.js');
const builtTokens = await import('./fixtures/build-custom-dir/src/tokens/index.js');

// rough token count
expect(Object.keys(builtTokens.meta).length).toBe(34);
// rough token count
expect(Object.keys(builtTokens.meta).length).toBe(34);
});
});
});

describe('docs examples', () => {
it('Guides: Getting Started', async () => {
const cwd = new URL('./fixtures/build-docs-examples/guides/getting-started', import.meta.url);
await execa('node', [`../../${cmd}`, 'build'], { cwd });
describe('docs examples', () => {
it('Guides: Getting Started', async () => {
const cwd = new URL('./fixtures/build-docs-examples/guides/getting-started', import.meta.url);
await execa('node', [`../../${cmd}`, 'build'], { cwd });

const builtTokens = await import('./fixtures/build-docs-examples/guides/getting-started/tokens/index.js');
const builtTokens = await import('./fixtures/build-docs-examples/guides/getting-started/tokens/index.js');

expect(Object.keys(builtTokens.meta).length).toBe(29);
expect(Object.keys(builtTokens.meta).length).toBe(29);
});
});
});
});
},
{ timeout: 10000 }, // Windows needs more time
);
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
Loading