Skip to content
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
4 changes: 2 additions & 2 deletions .github/workflows/autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/components/LicenseDisplay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const hasAnyValidLicense = computed(() => tokens.value.some(t => t.type === 'lic
{{ token.value }}
</a>
<span v-else-if="token.type === 'license'">{{ token.value }}</span>
<span v-else-if="token.type === 'operator'" class="text-[0.65em]">{{ token.value }}</span>
<span v-else-if="token.type === 'operator'" class="text-4xs">{{ token.value }}</span>
</template>
<span
v-if="hasAnyValidLicense"
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
"i18n:check:fix": "node scripts/compare-translations.ts --fix",
"i18n:report": "node scripts/find-invalid-translations.ts",
"i18n:report:fix": "node scripts/remove-unused-translations.ts",
"rtl:check": "node scripts/rtl-checker.ts",
"knip": "knip",
"knip:fix": "knip --fix",
"lint": "oxlint && oxfmt --check",
"lint:fix": "oxlint --fix && oxfmt",
"lint:css": "node scripts/unocss-checker.ts",
"generate": "nuxt generate",
"npmx-connector": "pnpm --filter npmx-connector dev",
"generate-pwa-icons": "pwa-assets-generator",
Expand Down Expand Up @@ -151,6 +151,9 @@
"*.{js,ts,mjs,cjs,vue}": [
"pnpm oxlint --fix"
],
"*.vue": [
"pnpm lint:css"
],
"*.{js,ts,mjs,cjs,vue,json,yml,md,html,css}": [
"pnpm oxfmt"
]
Expand Down
20 changes: 18 additions & 2 deletions scripts/rtl-checker.ts → scripts/unocss-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
import { createGenerator } from 'unocss'
import { presetRtl } from '../uno-preset-rtl.ts'
import { presetA11y } from '../uno-preset-a11y.ts'
import { COLORS } from './utils.ts'
import { presetWind4 } from 'unocss'

const argvFiles = process.argv.slice(2)
const APP_DIRECTORY = fileURLToPath(new URL('../app', import.meta.url))

async function checkFile(path: Dirent): Promise<string | undefined> {
Expand All @@ -33,6 +35,17 @@ async function checkFile(path: Dirent): Promise<string | undefined> {
`${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')
Expand All @@ -46,7 +59,10 @@ async function checkFile(path: Dirent): Promise<string | undefined> {
}

async function check(): Promise<void> {
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)
Expand All @@ -61,7 +77,7 @@ async function check(): Promise<void> {
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}`)
}
}

Expand Down
94 changes: 94 additions & 0 deletions test/unit/uno-preset-a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -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-<size> classes or rem values instead of custom values.",
"[a11y] Avoid using 'text-[1.5em]', use text-<size> 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-<size> classes or rem values instead of custom values.",
"text-[12px]",
],
[
"[a11y] Avoid using 'text-[1.5em]', use text-<size> classes or rem values instead of custom values.",
"text-[1.5em]",
],
]
`)
})
})
77 changes: 77 additions & 0 deletions uno-preset-a11y.ts
Original file line number Diff line number Diff line change
@@ -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<string>()

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<number, string> = {
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-<size> classes or rem values instead of custom values',
checker,
)
}
return [['font-size', `${num}px`]]
},
{ autocomplete: 'text-[<num>]' },
],
[
/^text-\[(\d+(\.\d+)?)em\]$/,
([match, numStr], context) => {
const num = Number(numStr)
const fullClass = context.rawSelector || match
reportTextSizeWarning(
fullClass,
'use text-<size> classes or rem values instead of custom values',
checker,
)
return [['font-size', `${num}em`]]
},
{ autocomplete: 'text-[<num>]em' },
],
],
}
}
3 changes: 2 additions & 1 deletion uno.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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: {
Expand Down
Loading