@gusnips/vite 0.8.44 → 0.8.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -1
- package/dist/contrast.d.ts +66 -1
- package/dist/contrast.d.ts.map +1 -1
- package/dist/contrast.js +297 -9
- package/dist/contrast.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/contrast-tokens.test.ts +293 -0
- package/src/contrast.ts +401 -8
- package/src/fixtures/contrast/tokens/ok-media.css +32 -0
- package/src/fixtures/contrast/tokens/ok.css +30 -0
- package/src/index.ts +1 -1
package/src/contrast.ts
CHANGED
|
@@ -30,9 +30,20 @@
|
|
|
30
30
|
* - **dead**: a class that compiles to no rule at all — a token Tailwind does not have, or a
|
|
31
31
|
* name the scanner caught half of. A warning: dynamic construction reads the same way.
|
|
32
32
|
*
|
|
33
|
+
* `checkContrast` is the Tailwind reading. `checkTokenPairs` (further down) is the other one, for
|
|
34
|
+
* an app whose colours are plain CSS custom properties: it reads a light block and a dark block as
|
|
35
|
+
* text, holds a list of pairs to a floor in both, and reports through the same problems and the
|
|
36
|
+
* same formatter. It adds two rules:
|
|
37
|
+
*
|
|
38
|
+
* - **missing**: a colour one theme declares and the other does not, or a pair that names a token
|
|
39
|
+
* neither declares. A typo in a pair would otherwise read as a pair that passes.
|
|
40
|
+
* - **mirror**: the system fallback (the dark colours repeated under `prefers-color-scheme` for a
|
|
41
|
+
* page that has chosen no theme) differs from the dark block, or sits outside that query.
|
|
42
|
+
*
|
|
33
43
|
* Runs on node and bun: `fs.promises.glob`, never Bun's `Glob`, and no `import.meta.dir`.
|
|
34
|
-
*
|
|
35
|
-
* `@gusnips/vite/contrast`), because the fixture is compiled, not guessed.
|
|
44
|
+
* `checkContrast` needs `@tailwindcss/cli` installed where it runs (an optional peer, reachable
|
|
45
|
+
* only from `@gusnips/vite/contrast`), because the fixture is compiled, not guessed.
|
|
46
|
+
* `checkTokenPairs` compiles nothing and needs no peer.
|
|
36
47
|
*/
|
|
37
48
|
import { spawnSync } from "node:child_process";
|
|
38
49
|
import { glob, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -56,7 +67,8 @@ export interface CheckContrastOptions {
|
|
|
56
67
|
root?: string;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
|
-
export type ContrastRule =
|
|
70
|
+
export type ContrastRule =
|
|
71
|
+
"load" | "floor" | "retune" | "rebind" | "untokened" | "hover" | "dead" | "missing" | "mirror";
|
|
60
72
|
|
|
61
73
|
export interface ContrastProblem {
|
|
62
74
|
rule: ContrastRule;
|
|
@@ -149,6 +161,13 @@ const ATTR = /\bclass(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|`((?:[^`$]|\$(?!\{))
|
|
|
149
161
|
const HELPER = /\b(?:cn|clsx|cva|twMerge|twJoin)\s*\(([^)]*)\)/g;
|
|
150
162
|
const STR = /"([^"]*)"|'([^']*)'/g;
|
|
151
163
|
|
|
164
|
+
/** The 1-based line a character offset falls on. */
|
|
165
|
+
function lineAt(source: string, index: number): number {
|
|
166
|
+
let line = 1;
|
|
167
|
+
for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
|
|
168
|
+
return line;
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
const skipped = (file: string): boolean =>
|
|
153
172
|
/(?:^|\/)__tests__\//.test(file) || /\.(test|spec)\.[tj]sx?$/.test(file);
|
|
154
173
|
|
|
@@ -164,11 +183,6 @@ async function scanSources(
|
|
|
164
183
|
if (!skipped(file)) files.add(file);
|
|
165
184
|
}
|
|
166
185
|
}
|
|
167
|
-
const lineAt = (source: string, index: number): number => {
|
|
168
|
-
let line = 1;
|
|
169
|
-
for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
|
|
170
|
-
return line;
|
|
171
|
-
};
|
|
172
186
|
const keep = (raw: string, file: string, index: number, source: string): void => {
|
|
173
187
|
for (const cls of raw.split(/\s+/).filter(Boolean)) {
|
|
174
188
|
// A token that would break the fixture's quoting is skipped rather than escaped:
|
|
@@ -506,3 +520,382 @@ export function formatContrastReport(report: ContrastReport): string {
|
|
|
506
520
|
);
|
|
507
521
|
return lines.join("\n");
|
|
508
522
|
}
|
|
523
|
+
|
|
524
|
+
// ── Plain CSS tokens ──────────────────────────────────────────────────────────
|
|
525
|
+
// The same gate for an app with no Tailwind `@theme`: its colours are custom properties in a
|
|
526
|
+
// light block and a dark block, and a list of pairs that must stay readable in both. The file is
|
|
527
|
+
// read as text and nothing is compiled, so this reading installs no optional peer.
|
|
528
|
+
//
|
|
529
|
+
// The limits are stated rather than hidden: hex colours only (a pair that names anything else
|
|
530
|
+
// fails as unmeasurable, never passes), blocks found by their selector text, and a block that
|
|
531
|
+
// holds another block (nested CSS) is not read. There is no CSS parser here. If themes outgrow
|
|
532
|
+
// flat blocks, replace `leafRules` with one.
|
|
533
|
+
|
|
534
|
+
/** Two custom properties, `[background, foreground]`, written as the CSS names them:
|
|
535
|
+
* `["--ground", "--ink"]`. The ratio is the same either way round; the order only decides how a
|
|
536
|
+
* problem reads. */
|
|
537
|
+
export type TokenPair = readonly [background: string, foreground: string];
|
|
538
|
+
|
|
539
|
+
export interface TokenPairsBundle {
|
|
540
|
+
/** Shown beside every problem: `"apps/site"`. */
|
|
541
|
+
name: string;
|
|
542
|
+
/** The stylesheet holding the colour blocks, relative to `root`. */
|
|
543
|
+
css: string;
|
|
544
|
+
/** The selector of the light block. Default `":root"`. It must differ from `dark`. */
|
|
545
|
+
light?: string;
|
|
546
|
+
/**
|
|
547
|
+
* The selector of the dark block: `':root[data-theme="dark"]'`. Leave it off for a light-only
|
|
548
|
+
* stylesheet: each pair is then measured once, and no token needs a dark twin.
|
|
549
|
+
*/
|
|
550
|
+
dark?: string;
|
|
551
|
+
/**
|
|
552
|
+
* The selector of the system fallback: the dark colours again, for a page that has chosen no
|
|
553
|
+
* theme, inside `@media (prefers-color-scheme: dark)`: `":root:not([data-theme])"`. It must
|
|
554
|
+
* hold exactly the dark block's tokens and values. Leave it off when there is none, and always
|
|
555
|
+
* when there is no `dark` block.
|
|
556
|
+
*/
|
|
557
|
+
system?: string;
|
|
558
|
+
/** Pairs read as text. Held to `textFloor`. */
|
|
559
|
+
textPairs?: readonly TokenPair[];
|
|
560
|
+
/** Pairs that are not text: borders, focus rings, icons. Held to `nonTextFloor`. */
|
|
561
|
+
nonTextPairs?: readonly TokenPair[];
|
|
562
|
+
/** Default 4.5 (WCAG 1.4.3). */
|
|
563
|
+
textFloor?: number;
|
|
564
|
+
/** Default 3 (WCAG 1.4.11). */
|
|
565
|
+
nonTextFloor?: number;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** The hex colours of each theme, `--ground` → `#ffffff`, for a check of your own. */
|
|
569
|
+
export interface TokenThemes {
|
|
570
|
+
light: Map<string, string>;
|
|
571
|
+
dark: Map<string, string>;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
interface TokenDecl {
|
|
575
|
+
value: string;
|
|
576
|
+
line: number;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
interface CssRule {
|
|
580
|
+
selectors: string[];
|
|
581
|
+
/** The at-rules around it, outermost first: `@media screen @media (prefers-color-scheme: dark)`. */
|
|
582
|
+
within: string;
|
|
583
|
+
tokens: Map<string, TokenDecl>;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
interface TokenBlock {
|
|
587
|
+
tokens: Map<string, TokenDecl>;
|
|
588
|
+
/** `within` of every block that matched, since one selector may be written twice. */
|
|
589
|
+
within: string[];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
interface TokenBlocks {
|
|
593
|
+
light: TokenBlock;
|
|
594
|
+
/** `null` for a light-only bundle, one that names no dark block. */
|
|
595
|
+
dark: TokenBlock | null;
|
|
596
|
+
/** `null` when the bundle names no system block, or names one that is not in the file. */
|
|
597
|
+
system: TokenBlock | null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const PREFERS_DARK = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/;
|
|
601
|
+
|
|
602
|
+
/** `:root[data-theme='dark']` and `:root[data-theme="dark"]` are the same selector. */
|
|
603
|
+
const selectorOf = (text: string): string => text.replace(/\s+/g, " ").replace(/'/g, '"').trim();
|
|
604
|
+
|
|
605
|
+
/** The comparable form of a value: `#FFF` and `#ffffff` are one colour. */
|
|
606
|
+
const canonical = (value: string): string =>
|
|
607
|
+
normalizeHex(value) ?? value.replace(/\s+/g, " ").toLowerCase();
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Every innermost `selector { … }` block with the at-rules around it, and the custom properties
|
|
611
|
+
* it declares. Text, not syntax: a brace or semicolon inside a string or `url()` is not understood.
|
|
612
|
+
*/
|
|
613
|
+
function leafRules(css: string): CssRule[] {
|
|
614
|
+
// Comments become spaces, so a `{` in one is not a block and every offset still names its line.
|
|
615
|
+
const source = css.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "));
|
|
616
|
+
const rules: CssRule[] = [];
|
|
617
|
+
const open: Array<{ prelude: string; bodyAt: number; nested: boolean }> = [];
|
|
618
|
+
let preludeFrom = 0;
|
|
619
|
+
for (let i = 0; i < source.length; i++) {
|
|
620
|
+
const char = source[i];
|
|
621
|
+
if (char === "{") {
|
|
622
|
+
const parent = open.at(-1);
|
|
623
|
+
if (parent !== undefined) parent.nested = true;
|
|
624
|
+
open.push({ prelude: source.slice(preludeFrom, i).trim(), bodyAt: i + 1, nested: false });
|
|
625
|
+
preludeFrom = i + 1;
|
|
626
|
+
} else if (char === "}") {
|
|
627
|
+
const block = open.pop();
|
|
628
|
+
preludeFrom = i + 1;
|
|
629
|
+
if (block === undefined || block.nested || block.prelude.startsWith("@")) continue;
|
|
630
|
+
const tokens = new Map<string, TokenDecl>();
|
|
631
|
+
for (const match of source.slice(block.bodyAt, i).matchAll(/(--[\w-]+)\s*:\s*([^;]*)/g)) {
|
|
632
|
+
tokens.set(match[1] ?? "", {
|
|
633
|
+
value: (match[2] ?? "").trim(),
|
|
634
|
+
line: lineAt(source, block.bodyAt + match.index),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
rules.push({
|
|
638
|
+
selectors: block.prelude.split(",").map(selectorOf),
|
|
639
|
+
within: open
|
|
640
|
+
.map((parent) => parent.prelude)
|
|
641
|
+
.filter((prelude) => prelude.startsWith("@"))
|
|
642
|
+
.join(" "),
|
|
643
|
+
tokens,
|
|
644
|
+
});
|
|
645
|
+
} else if (char === ";") {
|
|
646
|
+
preludeFrom = i + 1;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return rules;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Every block written with this selector, merged: a later token wins, as it does in CSS. */
|
|
653
|
+
function blockOf(rules: readonly CssRule[], selector: string): TokenBlock | null {
|
|
654
|
+
const wanted = selectorOf(selector);
|
|
655
|
+
const found = rules.filter((rule) => rule.selectors.includes(wanted));
|
|
656
|
+
if (found.length === 0) return null;
|
|
657
|
+
const tokens = new Map<string, TokenDecl>();
|
|
658
|
+
for (const rule of found) for (const [name, decl] of rule.tokens) tokens.set(name, decl);
|
|
659
|
+
return { tokens, within: found.map((rule) => rule.within) };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Throws when the file or a block the bundle names is not there. The system block may be absent:
|
|
663
|
+
* whether that is a problem is the check's question, not the loader's. */
|
|
664
|
+
async function readTokenBlocks(
|
|
665
|
+
bundle: Pick<TokenPairsBundle, "css" | "light" | "dark" | "system">,
|
|
666
|
+
root: string,
|
|
667
|
+
): Promise<TokenBlocks> {
|
|
668
|
+
const css = await readFile(resolve(root, bundle.css), "utf8").catch(() => null);
|
|
669
|
+
// A stylesheet that does not load reads as one with no tokens, which would pass every rule
|
|
670
|
+
// below. It fails instead, the same reason `checkContrast` has a load rule.
|
|
671
|
+
if (css === null) throw new Error(`${bundle.css} could not be read`);
|
|
672
|
+
const rules = leafRules(css);
|
|
673
|
+
const required = (label: string, selector: string): TokenBlock => {
|
|
674
|
+
const block = blockOf(rules, selector);
|
|
675
|
+
if (block === null) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
`${bundle.css} has no \`${selector}\` block for the ${label} colours. A block is found by ` +
|
|
678
|
+
`its selector text, inside any @media, and holds declarations only`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
return block;
|
|
682
|
+
};
|
|
683
|
+
const lightSelector = bundle.light ?? ":root";
|
|
684
|
+
if (bundle.dark === undefined) {
|
|
685
|
+
if (bundle.system !== undefined) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`a system block repeats the dark colours, and this bundle names no dark block. Name the ` +
|
|
688
|
+
`dark block, or leave \`system\` off for a light-only stylesheet`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
return { light: required("light", lightSelector), dark: null, system: null };
|
|
692
|
+
}
|
|
693
|
+
if (selectorOf(lightSelector) === selectorOf(bundle.dark)) {
|
|
694
|
+
throw new Error(`the light and dark selectors are both \`${lightSelector}\`; they must differ`);
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
light: required("light", lightSelector),
|
|
698
|
+
dark: required("dark", bundle.dark),
|
|
699
|
+
system: bundle.system === undefined ? null : blockOf(rules, bundle.system),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** The tokens whose value is a hex colour, as `#rrggbb`. Type and geometry are left out. */
|
|
704
|
+
function hexTokens(tokens: ReadonlyMap<string, TokenDecl>): Map<string, string> {
|
|
705
|
+
const hex = new Map<string, string>();
|
|
706
|
+
for (const [name, { value }] of tokens) {
|
|
707
|
+
const colour = normalizeHex(value);
|
|
708
|
+
if (colour !== null) hex.set(name, colour);
|
|
709
|
+
}
|
|
710
|
+
return hex;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* The two palettes of a bundle as hex maps, for a check of your own that needs the colours
|
|
715
|
+
* (the browser's toolbar colour should be the page's ground, in each theme):
|
|
716
|
+
*
|
|
717
|
+
* ```ts
|
|
718
|
+
* const { light, dark } = await loadTokenThemes({ css: "src/tokens.css", dark: '[data-theme="dark"]' });
|
|
719
|
+
* light.get("--ground"); // "#ffffff"
|
|
720
|
+
* ```
|
|
721
|
+
*
|
|
722
|
+
* Throws, saying what is missing, when the file or one of the two blocks is not there.
|
|
723
|
+
*/
|
|
724
|
+
export async function loadTokenThemes(
|
|
725
|
+
bundle: Pick<TokenPairsBundle, "css" | "light"> & { dark: string },
|
|
726
|
+
options: CheckContrastOptions = {},
|
|
727
|
+
): Promise<TokenThemes> {
|
|
728
|
+
const { light, dark } = await readTokenBlocks(bundle, options.root ?? process.cwd());
|
|
729
|
+
// Unreachable: a bundle that names a dark block either has one or throws above.
|
|
730
|
+
if (dark === null) throw new Error(`${bundle.css} has no \`${bundle.dark}\` block`);
|
|
731
|
+
return { light: hexTokens(light.tokens), dark: hexTokens(dark.tokens) };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function checkTokenBundle(
|
|
735
|
+
bundle: TokenPairsBundle,
|
|
736
|
+
options: CheckContrastOptions,
|
|
737
|
+
report: ContrastReport,
|
|
738
|
+
): Promise<void> {
|
|
739
|
+
const problem = (p: Omit<ContrastProblem, "bundle" | "level">): void => {
|
|
740
|
+
report.problems.push({ bundle: bundle.name, level: "error", ...p });
|
|
741
|
+
};
|
|
742
|
+
const at = (decl: TokenDecl | undefined): string =>
|
|
743
|
+
decl === undefined ? bundle.css : `${bundle.css}:${String(decl.line)}`;
|
|
744
|
+
|
|
745
|
+
let blocks: TokenBlocks;
|
|
746
|
+
try {
|
|
747
|
+
blocks = await readTokenBlocks(bundle, options.root ?? process.cwd());
|
|
748
|
+
} catch (error) {
|
|
749
|
+
problem({ rule: "load", message: error instanceof Error ? error.message : String(error) });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const { light, dark, system } = blocks;
|
|
753
|
+
const themes: Array<[mode: string, tokens: Map<string, TokenDecl>]> = [["light", light.tokens]];
|
|
754
|
+
if (dark !== null) themes.push(["dark", dark.tokens]);
|
|
755
|
+
const textPairs = bundle.textPairs ?? [];
|
|
756
|
+
const nonTextPairs = bundle.nonTextPairs ?? [];
|
|
757
|
+
|
|
758
|
+
// ── Every colour has both themes, and every pair names a real token ──
|
|
759
|
+
// A light-only bundle has one theme, so only the pairs' names are checked against it.
|
|
760
|
+
const wanted = new Set([
|
|
761
|
+
...(dark === null ? [] : [...hexTokens(light.tokens).keys(), ...hexTokens(dark.tokens).keys()]),
|
|
762
|
+
...[...textPairs, ...nonTextPairs].flat(),
|
|
763
|
+
]);
|
|
764
|
+
for (const token of wanted) {
|
|
765
|
+
const inLight = light.tokens.get(token);
|
|
766
|
+
if (dark === null) {
|
|
767
|
+
if (inLight === undefined) {
|
|
768
|
+
problem({
|
|
769
|
+
rule: "missing",
|
|
770
|
+
at: bundle.css,
|
|
771
|
+
message:
|
|
772
|
+
`a pair names \`${token}\`, which the light block does not declare` +
|
|
773
|
+
(token.startsWith("--") ? "" : ". Custom property names start with --"),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const inDark = dark.tokens.get(token);
|
|
779
|
+
if (inLight !== undefined && inDark !== undefined) continue;
|
|
780
|
+
if (inLight === undefined && inDark === undefined) {
|
|
781
|
+
problem({
|
|
782
|
+
rule: "missing",
|
|
783
|
+
at: bundle.css,
|
|
784
|
+
message:
|
|
785
|
+
`a pair names \`${token}\`, which neither the light nor the dark block declares` +
|
|
786
|
+
(token.startsWith("--") ? "" : ". Custom property names start with --"),
|
|
787
|
+
});
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
const [have, lack] = inLight === undefined ? ["dark", "light"] : ["light", "dark"];
|
|
791
|
+
problem({
|
|
792
|
+
rule: "missing",
|
|
793
|
+
at: at(inLight ?? inDark),
|
|
794
|
+
message:
|
|
795
|
+
`\`${token}\` is in the ${have} block but not in the ${lack} block. Declare it in both, ` +
|
|
796
|
+
`even when the colour is the same, so each theme says what it means`,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ── The pairs, on the values that ship in each theme ──
|
|
801
|
+
// A token missing from a theme was reported above, so its pairs are skipped here, not repeated.
|
|
802
|
+
const unmeasurable = new Set<string>();
|
|
803
|
+
const cannotMeasure = (mode: string, token: string, decl: TokenDecl): void => {
|
|
804
|
+
if (unmeasurable.has(`${mode}:${token}`)) return;
|
|
805
|
+
unmeasurable.add(`${mode}:${token}`);
|
|
806
|
+
problem({
|
|
807
|
+
rule: "floor",
|
|
808
|
+
at: at(decl),
|
|
809
|
+
message:
|
|
810
|
+
`${mode}: cannot measure ${token} (${decl.value}), because it is not a hex colour. ` +
|
|
811
|
+
`Keep the values hex, or teach this check the new notation; ` +
|
|
812
|
+
`do not let a pair go unmeasured.`,
|
|
813
|
+
});
|
|
814
|
+
};
|
|
815
|
+
const measure = (pairs: readonly TokenPair[], floor: number, why: string): void => {
|
|
816
|
+
for (const [background, foreground] of pairs) {
|
|
817
|
+
for (const [mode, tokens] of themes) {
|
|
818
|
+
const bg = tokens.get(background);
|
|
819
|
+
const fg = tokens.get(foreground);
|
|
820
|
+
if (bg === undefined || fg === undefined) continue;
|
|
821
|
+
const bgHex = normalizeHex(bg.value);
|
|
822
|
+
const fgHex = normalizeHex(fg.value);
|
|
823
|
+
if (bgHex === null || fgHex === null) {
|
|
824
|
+
if (bgHex === null) cannotMeasure(mode, background, bg);
|
|
825
|
+
if (fgHex === null) cannotMeasure(mode, foreground, fg);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const ratio = contrast(fgHex, bgHex);
|
|
829
|
+
if (ratio < floor) {
|
|
830
|
+
problem({
|
|
831
|
+
rule: "floor",
|
|
832
|
+
at: at(fg),
|
|
833
|
+
message:
|
|
834
|
+
`${mode}: ${foreground} (${fgHex}) on ${background} (${bgHex}) is ` +
|
|
835
|
+
`${ratio.toFixed(2)}:1, under the ${floor}:1 floor for ${why}`,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
measure(textPairs, bundle.textFloor ?? 4.5, "text (WCAG 1.4.3)");
|
|
842
|
+
measure(nonTextPairs, bundle.nonTextFloor ?? 3, "a control or icon (WCAG 1.4.11)");
|
|
843
|
+
|
|
844
|
+
// ── The system fallback says what the dark block says ──
|
|
845
|
+
if (bundle.system === undefined || dark === null) return;
|
|
846
|
+
const mirror = (decl: TokenDecl | undefined, message: string): void =>
|
|
847
|
+
problem({ rule: "mirror", at: at(decl), message });
|
|
848
|
+
if (system === null) {
|
|
849
|
+
mirror(
|
|
850
|
+
undefined,
|
|
851
|
+
`no \`${bundle.system}\` block for the system fallback. Without it a page that has ` +
|
|
852
|
+
`chosen no theme stays light on a dark system`,
|
|
853
|
+
);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (!system.within.every((within) => PREFERS_DARK.test(within))) {
|
|
857
|
+
mirror(
|
|
858
|
+
[...system.tokens.values()][0],
|
|
859
|
+
`\`${bundle.system}\` is not inside @media (prefers-color-scheme: dark), so it paints ` +
|
|
860
|
+
`the dark colours for everyone who has chosen no theme`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
for (const token of new Set([...dark.tokens.keys(), ...system.tokens.keys()])) {
|
|
864
|
+
const chosen = dark.tokens.get(token);
|
|
865
|
+
const followed = system.tokens.get(token);
|
|
866
|
+
if (chosen !== undefined && followed !== undefined) {
|
|
867
|
+
if (canonical(chosen.value) !== canonical(followed.value)) {
|
|
868
|
+
mirror(
|
|
869
|
+
followed,
|
|
870
|
+
`\`${token}\` is ${chosen.value} in the dark block but ${followed.value} in the system block`,
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
} else if (chosen !== undefined) {
|
|
874
|
+
mirror(chosen, `\`${token}\` is in the dark block but not in the system block`);
|
|
875
|
+
} else {
|
|
876
|
+
mirror(followed, `\`${token}\` is in the system block but not in the dark block`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* The plain-CSS gate: for each stylesheet, the contrast of every pair in both themes, a token
|
|
883
|
+
* both themes have, and a system fallback that matches the dark block. Returns what it found,
|
|
884
|
+
* the same report `checkContrast` returns, so `formatContrastReport` prints either and a repo
|
|
885
|
+
* can join the two (`report.problems.push(...other.problems)`) and fail CI once.
|
|
886
|
+
*
|
|
887
|
+
* ```ts
|
|
888
|
+
* const report = await checkTokenPairs([
|
|
889
|
+
* { name: "site", css: "src/tokens.css", dark: ':root[data-theme="dark"]', textPairs: [["--ground", "--ink"]] },
|
|
890
|
+
* ]);
|
|
891
|
+
* console.log(formatContrastReport(report));
|
|
892
|
+
* ```
|
|
893
|
+
*/
|
|
894
|
+
export async function checkTokenPairs(
|
|
895
|
+
bundles: readonly TokenPairsBundle[],
|
|
896
|
+
options: CheckContrastOptions = {},
|
|
897
|
+
): Promise<ContrastReport> {
|
|
898
|
+
const report: ContrastReport = { problems: [] };
|
|
899
|
+
for (const bundle of bundles) await checkTokenBundle(bundle, options, report);
|
|
900
|
+
return report;
|
|
901
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* The same palette with both dark blocks wrapped in @media. */
|
|
2
|
+
:root {
|
|
3
|
+
--ground: #ffffff;
|
|
4
|
+
--ink: #1a1a1a;
|
|
5
|
+
--muted: #555555;
|
|
6
|
+
--line: #767676;
|
|
7
|
+
--accent: #0b5cad;
|
|
8
|
+
--accent-ink: #fff;
|
|
9
|
+
--radius: 8px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@media screen {
|
|
13
|
+
:root[data-theme="dark"] {
|
|
14
|
+
--ground: #121212;
|
|
15
|
+
--ink: #f2f2f2;
|
|
16
|
+
--muted: #b3b3b3;
|
|
17
|
+
--line: #8a8a8a;
|
|
18
|
+
--accent: #7ab7ff;
|
|
19
|
+
--accent-ink: #0a1a2b;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root:not([data-theme]) {
|
|
24
|
+
--ground: #121212;
|
|
25
|
+
--ink: #f2f2f2;
|
|
26
|
+
--muted: #b3b3b3;
|
|
27
|
+
--line: #8a8a8a;
|
|
28
|
+
--accent: #7ab7ff;
|
|
29
|
+
--accent-ink: #0a1a2b;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* Flat colour blocks, written the way a plain-CSS app writes them. A {brace} in a comment is no block. */
|
|
2
|
+
:root {
|
|
3
|
+
--ground: #ffffff;
|
|
4
|
+
--ink: #1a1a1a;
|
|
5
|
+
--muted: #555555;
|
|
6
|
+
--line: #767676;
|
|
7
|
+
--accent: #0b5cad;
|
|
8
|
+
--accent-ink: #fff;
|
|
9
|
+
--radius: 8px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
:root[data-theme="dark"] {
|
|
13
|
+
--ground: #121212;
|
|
14
|
+
--ink: #f2f2f2;
|
|
15
|
+
--muted: #b3b3b3;
|
|
16
|
+
--line: #8a8a8a;
|
|
17
|
+
--accent: #7ab7ff;
|
|
18
|
+
--accent-ink: #0a1a2b;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@media (prefers-color-scheme: dark) {
|
|
22
|
+
:root:not([data-theme]) {
|
|
23
|
+
--ground: #121212;
|
|
24
|
+
--ink: #f2f2f2;
|
|
25
|
+
--muted: #b3b3b3;
|
|
26
|
+
--line: #8a8a8a;
|
|
27
|
+
--accent: #7ab7ff;
|
|
28
|
+
--accent-ink: #0a1a2b;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* otherwise force on everyone: the vite config preset at `@gusnips/vite/preset` (the React and
|
|
14
14
|
* Tailwind plugins), `renderTree` at `@gusnips/vite/render`, the theme pre-paint plugin at
|
|
15
15
|
* `@gusnips/vite/theme` (both React itself), the catalog check at `@gusnips/vite/i18n`, and the
|
|
16
|
-
* contrast gate at `@gusnips/vite/contrast` (tailwindcss). The last two run in CI and never in a
|
|
16
|
+
* contrast gate at `@gusnips/vite/contrast` (tailwindcss, for the class reading only). The last two run in CI and never in a
|
|
17
17
|
* build. Nothing here imports React, vite or tailwindcss, so a prerender script, an OG generator
|
|
18
18
|
* and a repo that only wants a sitemap all install exactly what they use.
|
|
19
19
|
*/
|