diff --git a/src/styles/signin.css b/src/styles/signin.css index 5beac15..2da8d81 100644 --- a/src/styles/signin.css +++ b/src/styles/signin.css @@ -100,5 +100,4 @@ } .signin-field input::placeholder { color: var(--ink-3); } .signin-form .signin-submit { margin-top: 18px; width: 100%; height: 54px; } -.signin-form .signin-submit:disabled { opacity: .5; cursor: not-allowed; } .signin-error { margin: 0; } diff --git a/tests/disabled-opacity.test.ts b/tests/disabled-opacity.test.ts new file mode 100644 index 0000000..6d21d36 --- /dev/null +++ b/tests/disabled-opacity.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +/** Every .css file under src/ (src/styles, src/app/globals.css, and any other). */ +function cssFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) return cssFiles(full); + return full.endsWith(".css") ? [full] : []; + }); +} + +/** Rules whose selector targets a disabled state and whose opacity is below 1. */ +function fadedDisabledRules(source: string): string[] { + const text = source.replace(/\/\*[\s\S]*?\*\//g, ""); + const found: string[] = []; + // Innermost blocks only: the selector text runs back to the previous brace, + // so rules nested inside @media or @supports are scanned too. + for (const m of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const selector = m[1].trim(); + if (!/:disabled|\[disabled\]|\[aria-disabled(="true")?\]/.test(selector)) continue; + for (const o of m[2].matchAll(/(?:^|;|\s)opacity\s*:\s*([\d.]+)(%?)/g)) { + const value = parseFloat(o[1]) / (o[2] ? 100 : 1); + if (value < 1) found.push(`${selector} { opacity: ${o[1]}${o[2]} }`); + } + } + return found; +} + +describe("disabled buttons are never faded", () => { + const root = path.resolve(__dirname, "../src"); + const files = cssFiles(root); + + it("finds the stylesheets, globals.css included", () => { + expect(files.some((f) => f.endsWith(path.join("app", "globals.css")))).toBe(true); + expect(files.some((f) => f.endsWith(path.join("styles", "signin.css")))).toBe(true); + }); + + it("the scanner catches a faded disabled rule", () => { + expect(fadedDisabledRules(".a:disabled { opacity: .5; }")).toHaveLength(1); + expect(fadedDisabledRules("@media (x) { .a[disabled] { color: red; opacity: 40% } }")).toHaveLength(1); + expect(fadedDisabledRules(".a:disabled { opacity: 1; }")).toHaveLength(0); + expect(fadedDisabledRules(".a:hover { opacity: .5; }")).toHaveLength(0); + }); + + for (const file of files) { + it(`${path.relative(root, file)} has no :disabled rule with opacity below 1`, () => { + expect(fadedDisabledRules(readFileSync(file, "utf8"))).toEqual([]); + }); + } +});