From bab534e9c13e0a27fdab7dada8ce69008fb7e413 Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 8 Feb 2026 10:04:23 +0000 Subject: [PATCH 1/2] test: configure lint-css to check a11y and rtl --- .github/workflows/autofix.yml | 4 +- app/components/LicenseDisplay.vue | 2 +- package.json | 5 +- scripts/{rtl-checker.ts => unocss-checker.ts} | 20 ++++- uno-preset-a11y.ts | 77 +++++++++++++++++++ uno.config.ts | 3 +- 6 files changed, 104 insertions(+), 7 deletions(-) rename scripts/{rtl-checker.ts => unocss-checker.ts} (73%) create mode 100644 uno-preset-a11y.ts diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 8a5effea2..619aae3af 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -32,8 +32,8 @@ jobs: - name: 📦 Install dependencies run: pnpm install - - name: 🎨 Check for non-RTL CSS classes - run: pnpm rtl:check + - name: 🎨 Check for non-RTL/non-a11y CSS classes + run: pnpm lint:css - name: 🌐 Compare translations run: pnpm i18n:check diff --git a/app/components/LicenseDisplay.vue b/app/components/LicenseDisplay.vue index 512248c87..eda37feec 100644 --- a/app/components/LicenseDisplay.vue +++ b/app/components/LicenseDisplay.vue @@ -24,7 +24,7 @@ const hasAnyValidLicense = computed(() => tokens.value.some(t => t.type === 'lic {{ token.value }} {{ token.value }} - {{ token.value }} + {{ token.value }} { @@ -33,6 +35,17 @@ async function checkFile(path: Dirent): Promise { `${COLORS.red} ❌ [RTL] ${filename}:${idx}${ruleIdx > -1 ? `:${ruleIdx + 1}` : ''} - ${warning}${COLORS.reset}`, ) }), + presetA11y((warning, rule) => { + let entry = warnings.get(idx) + if (!entry) { + entry = [] + warnings.set(idx, entry) + } + const ruleIdx = line.indexOf(rule) + entry.push( + `${COLORS.red} ❌ [A11y] ${filename}:${idx}${ruleIdx > -1 ? `:${ruleIdx + 1}` : ''} - ${warning}${COLORS.reset}`, + ) + }), ], }) const lines = file.split('\n') @@ -46,7 +59,10 @@ async function checkFile(path: Dirent): Promise { } async function check(): Promise { - const dir = glob('**/*.vue', { withFileTypes: true, cwd: APP_DIRECTORY }) + const dir = glob(argvFiles.length > 0 ? argvFiles : '**/*.vue', { + withFileTypes: true, + cwd: APP_DIRECTORY, + }) let hasErrors = false for await (const file of dir) { const result = await checkFile(file) @@ -61,7 +77,7 @@ async function check(): Promise { process.exit(1) } else { // oxlint-disable-next-line no-console -- success logging - console.log(`${COLORS.green}✅ CSS RTL check passed!${COLORS.reset}`) + console.log(`${COLORS.green}✅ CSS check passed!${COLORS.reset}`) } } diff --git a/uno-preset-a11y.ts b/uno-preset-a11y.ts new file mode 100644 index 000000000..260ba3b7a --- /dev/null +++ b/uno-preset-a11y.ts @@ -0,0 +1,77 @@ +import type { Preset } from 'unocss' + +export type CollectorChecker = (warning: string, rule: string) => void + +// Track warnings to avoid duplicates +const warnedClasses = new Set() + +function warnOnce(message: string, key: string) { + if (!warnedClasses.has(key)) { + warnedClasses.add(key) + // oxlint-disable-next-line no-console -- warn logging + console.warn(message) + } +} + +/** Reset warning state (for testing) */ +export function resetA11yWarnings() { + warnedClasses.clear() +} + +const textPxToClass: Record = { + 11: 'text-2xs', + 10: 'text-3xs', + 9: 'text-4xs', + 8: 'text-5xs', +} + +function reportTextSizeWarning(match: string, suggestion: string, checker?: CollectorChecker) { + const message = `[A11y] Avoid using '${match}', ${suggestion}.` + if (checker) { + checker(message, match) + } else { + warnOnce(message, match) + } +} + +export function presetA11y(checker?: CollectorChecker): Preset { + return { + name: 'a11y-preset', + // text-[N] (arbitrary where N is a size in px or em): recommend text-2xs/text-3xs/text-4xs/text-5xs or "use classes" + rules: [ + [ + /^text-\[(\d+(\.\d+)?)(px)?\]$/, + ([match, numStr], context) => { + const num = Number(numStr) + const fullClass = context.rawSelector || match + const suggestedClass = textPxToClass[num] + if (suggestedClass) { + reportTextSizeWarning(fullClass, `use '${suggestedClass}' instead`, checker) + } else { + reportTextSizeWarning( + fullClass, + 'use text- classes or rem values instead of custom values', + checker, + ) + } + return [['font-size', `${num}px`]] + }, + { autocomplete: 'text-[]' }, + ], + [ + /^text-\[(\d+(\.\d+)?)em\]$/, + ([match, numStr], context) => { + const num = Number(numStr) + const fullClass = context.rawSelector || match + reportTextSizeWarning( + fullClass, + 'use text- classes or rem values instead of custom values', + checker, + ) + return [['font-size', `${num}em`]] + }, + { autocomplete: 'text-[]em' }, + ], + ], + } +} diff --git a/uno.config.ts b/uno.config.ts index a9bbeba56..b75446763 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -7,6 +7,7 @@ import { } from 'unocss' import type { Theme } from '@unocss/preset-wind4/theme' import { presetRtl } from './uno-preset-rtl' +import { presetA11y } from './uno-preset-a11y' const customIcons = { 'agent-skills': @@ -32,7 +33,7 @@ export default defineConfig({ }, }), // keep this preset last - ...(process.env.CI ? [] : [presetRtl()]), + ...(process.env.CI ? [] : [presetRtl(), presetA11y()]), ].filter(Boolean), transformers: [transformerDirectives(), transformerVariantGroup()], theme: { From 20eb43b99f1b9baccf23fba65e74afde25466d5d Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 8 Feb 2026 10:48:02 +0000 Subject: [PATCH 2/2] test: add tests for uno-preset-a11y --- test/unit/uno-preset-a11y.spec.ts | 94 +++++++++++++++++++++++++++++++ uno-preset-a11y.ts | 2 +- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 test/unit/uno-preset-a11y.spec.ts diff --git a/test/unit/uno-preset-a11y.spec.ts b/test/unit/uno-preset-a11y.spec.ts new file mode 100644 index 000000000..49620d34b --- /dev/null +++ b/test/unit/uno-preset-a11y.spec.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { presetA11y, resetA11yWarnings } from '../../uno-preset-a11y' +import { createGenerator, presetWind4 } from 'unocss' + +describe('uno-preset-a11y', () => { + let warnSpy: MockInstance + + beforeEach(() => { + resetA11yWarnings() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('a11y rules generate font-size and warn correctly', async () => { + const uno = await createGenerator({ + presets: [presetWind4(), presetA11y()], + }) + + const { css } = await uno.generate( + 'text-[11px] text-[10px] text-[9px] text-[8px] text-[12px] text-[1.5em]', + ) + + expect(css).toMatchInlineSnapshot(` + "/* layer: theme */ + :root, :host { --font-sans: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; --default-font-family: var(--font-sans); --default-monoFont-family: var(--font-mono); } + /* layer: base */ + *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' ); font-feature-settings: var(--default-font-featureSettings, normal); font-variation-settings: var(--default-font-variationSettings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var( --default-monoFont-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace ); font-feature-settings: var(--default-monoFont-featureSettings, normal); font-variation-settings: var(--default-monoFont-variationSettings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden~='until-found'])) { display: none !important; } + /* layer: default */ + .text-\\[10px\\]{font-size:10px;} + .text-\\[11px\\]{font-size:11px;} + .text-\\[12px\\]{font-size:12px;} + .text-\\[8px\\]{font-size:8px;} + .text-\\[9px\\]{font-size:9px;} + .text-\\[1\\.5em\\]{font-size:1.5em;}" + `) + + const warnings = warnSpy.mock.calls.flat() + expect(warnings).toMatchInlineSnapshot(` + [ + "[a11y] Avoid using 'text-[11px]', use 'text-2xs' instead.", + "[a11y] Avoid using 'text-[10px]', use 'text-3xs' instead.", + "[a11y] Avoid using 'text-[9px]', use 'text-4xs' instead.", + "[a11y] Avoid using 'text-[8px]', use 'text-5xs' instead.", + "[a11y] Avoid using 'text-[12px]', use text- classes or rem values instead of custom values.", + "[a11y] Avoid using 'text-[1.5em]', use text- classes or rem values instead of custom values.", + ] + `) + }) + + it('when checker is provided, checker is called and no console.warn', async () => { + const collected: Array<[string, string]> = [] + const checker = (warning: string, rule: string) => { + collected.push([warning, rule]) + } + + const uno = await createGenerator({ + presets: [presetWind4(), presetA11y(checker)], + }) + + const { css } = await uno.generate('text-[11px] text-[12px] text-[1.5em]') + + expect(css).toMatchInlineSnapshot(` + "/* layer: theme */ + :root, :host { --font-sans: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; --default-font-family: var(--font-sans); --default-monoFont-family: var(--font-mono); } + /* layer: base */ + *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' ); font-feature-settings: var(--default-font-featureSettings, normal); font-variation-settings: var(--default-font-variationSettings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var( --default-monoFont-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace ); font-feature-settings: var(--default-monoFont-featureSettings, normal); font-variation-settings: var(--default-monoFont-variationSettings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden~='until-found'])) { display: none !important; } + /* layer: default */ + .text-\\[11px\\]{font-size:11px;} + .text-\\[12px\\]{font-size:12px;} + .text-\\[1\\.5em\\]{font-size:1.5em;}" + `) + + expect(warnSpy).not.toHaveBeenCalled() + expect(collected).toMatchInlineSnapshot(` + [ + [ + "[a11y] Avoid using 'text-[11px]', use 'text-2xs' instead.", + "text-[11px]", + ], + [ + "[a11y] Avoid using 'text-[12px]', use text- classes or rem values instead of custom values.", + "text-[12px]", + ], + [ + "[a11y] Avoid using 'text-[1.5em]', use text- classes or rem values instead of custom values.", + "text-[1.5em]", + ], + ] + `) + }) +}) diff --git a/uno-preset-a11y.ts b/uno-preset-a11y.ts index 260ba3b7a..589752bc5 100644 --- a/uno-preset-a11y.ts +++ b/uno-preset-a11y.ts @@ -26,7 +26,7 @@ const textPxToClass: Record = { } function reportTextSizeWarning(match: string, suggestion: string, checker?: CollectorChecker) { - const message = `[A11y] Avoid using '${match}', ${suggestion}.` + const message = `[a11y] Avoid using '${match}', ${suggestion}.` if (checker) { checker(message, match) } else {