@meddleware/frontend-config 0.0.0-stage → 0.0.1
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/CHANGELOG.md +14 -0
- package/LICENSE +14 -0
- package/README.md +114 -2
- package/dist/a11y.d.ts +28 -0
- package/dist/axe.d.ts +12 -0
- package/dist/eslint.d.ts +33 -0
- package/dist/html-validate.d.ts +30 -0
- package/dist/index.d.ts +6 -0
- package/dist/lighthouserc.d.ts +54 -0
- package/dist/look.d.ts +35 -0
- package/dist/playwright.d.ts +21 -0
- package/dist/postcss.d.ts +5 -0
- package/dist/shots.d.ts +10 -0
- package/dist/stylelint.d.ts +105 -0
- package/dist/vite.d.ts +43 -0
- package/dist/vitest.d.ts +11 -0
- package/dist/wallet.d.ts +42 -0
- package/package.json +159 -4
- package/src/a11y.js +129 -0
- package/src/axe.js +43 -0
- package/src/browserslist-config.cjs +21 -0
- package/src/eslint.js +88 -0
- package/src/html-validate.js +49 -0
- package/src/index.js +12 -0
- package/src/lighthouserc.js +39 -0
- package/src/look.js +59 -0
- package/src/playwright.js +62 -0
- package/src/postcss.js +7 -0
- package/src/shots.js +33 -0
- package/src/stylelint.js +107 -0
- package/src/vite.js +87 -0
- package/src/vitest.js +52 -0
- package/src/wallet.js +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,161 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/frontend-config",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shared frontend tooling for MeddleWare apps: browser support, ESLint, Stylelint, html-validate, PostCSS, Playwright, axe, screenshots, Lighthouse and the Vite target.",
|
|
6
|
+
"license": "0BSD",
|
|
7
|
+
"author": "MeddleWare <dev@meddleware.co.uk>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/meddleware-org/frontend-config.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"browserslist",
|
|
14
|
+
"eslint",
|
|
15
|
+
"stylelint",
|
|
16
|
+
"html-validate",
|
|
17
|
+
"playwright",
|
|
18
|
+
"accessibility",
|
|
19
|
+
"wcag",
|
|
20
|
+
"vue"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"dist",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./src/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./browserslist-config": "./src/browserslist-config.cjs",
|
|
33
|
+
"./eslint": {
|
|
34
|
+
"types": "./dist/eslint.d.ts",
|
|
35
|
+
"default": "./src/eslint.js"
|
|
36
|
+
},
|
|
37
|
+
"./stylelint": {
|
|
38
|
+
"types": "./dist/stylelint.d.ts",
|
|
39
|
+
"default": "./src/stylelint.js"
|
|
40
|
+
},
|
|
41
|
+
"./html-validate": {
|
|
42
|
+
"types": "./dist/html-validate.d.ts",
|
|
43
|
+
"default": "./src/html-validate.js"
|
|
44
|
+
},
|
|
45
|
+
"./postcss": {
|
|
46
|
+
"types": "./dist/postcss.d.ts",
|
|
47
|
+
"default": "./src/postcss.js"
|
|
48
|
+
},
|
|
49
|
+
"./playwright": {
|
|
50
|
+
"types": "./dist/playwright.d.ts",
|
|
51
|
+
"default": "./src/playwright.js"
|
|
52
|
+
},
|
|
53
|
+
"./look": {
|
|
54
|
+
"types": "./dist/look.d.ts",
|
|
55
|
+
"default": "./src/look.js"
|
|
56
|
+
},
|
|
57
|
+
"./axe": {
|
|
58
|
+
"types": "./dist/axe.d.ts",
|
|
59
|
+
"default": "./src/axe.js"
|
|
60
|
+
},
|
|
61
|
+
"./a11y": {
|
|
62
|
+
"types": "./dist/a11y.d.ts",
|
|
63
|
+
"default": "./src/a11y.js"
|
|
64
|
+
},
|
|
65
|
+
"./shots": {
|
|
66
|
+
"types": "./dist/shots.d.ts",
|
|
67
|
+
"default": "./src/shots.js"
|
|
68
|
+
},
|
|
69
|
+
"./lighthouserc": {
|
|
70
|
+
"types": "./dist/lighthouserc.d.ts",
|
|
71
|
+
"default": "./src/lighthouserc.js"
|
|
72
|
+
},
|
|
73
|
+
"./vite": {
|
|
74
|
+
"types": "./dist/vite.d.ts",
|
|
75
|
+
"default": "./src/vite.js"
|
|
76
|
+
},
|
|
77
|
+
"./vitest": {
|
|
78
|
+
"types": "./dist/vitest.d.ts",
|
|
79
|
+
"default": "./src/vitest.js"
|
|
80
|
+
},
|
|
81
|
+
"./wallet": {
|
|
82
|
+
"types": "./dist/wallet.d.ts",
|
|
83
|
+
"default": "./src/wallet.js"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"scripts": {
|
|
87
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
88
|
+
"prepublishOnly": "npm run build",
|
|
89
|
+
"type-check": "tsc --noEmit",
|
|
90
|
+
"test": "vitest run"
|
|
91
|
+
},
|
|
92
|
+
"dependencies": {
|
|
93
|
+
"@axe-core/playwright": "~4.14.0",
|
|
94
|
+
"@double-great/stylelint-a11y": "^3.8.1",
|
|
95
|
+
"@meddleware/eslint-config": "^0.0.4",
|
|
96
|
+
"@vue/eslint-config-typescript": "^14.9.0",
|
|
97
|
+
"autoprefixer": "^10.6.2",
|
|
98
|
+
"browserslist-to-esbuild": "^2.1.1",
|
|
99
|
+
"eslint-plugin-compat": "^7.0.2",
|
|
100
|
+
"eslint-plugin-vue": "^10.12.0",
|
|
101
|
+
"eslint-plugin-vuejs-accessibility": "^2.6.3",
|
|
102
|
+
"html-validate-vue": "^9.3.1",
|
|
103
|
+
"postcss-html": "^2.0.0",
|
|
104
|
+
"stylelint-config-standard": "^40.0.0",
|
|
105
|
+
"stylelint-declaration-strict-value": "^1.12.1",
|
|
106
|
+
"stylelint-no-unsupported-browser-features": "^8.1.2",
|
|
107
|
+
"typescript-eslint": "^8.72.1",
|
|
108
|
+
"vue-eslint-parser": "^10.4.1"
|
|
109
|
+
},
|
|
110
|
+
"peerDependencies": {
|
|
111
|
+
"@playwright/test": "~1.64.1",
|
|
112
|
+
"eslint": "^10.0.0",
|
|
113
|
+
"html-validate": "^11.0.0",
|
|
114
|
+
"postcss": "^8.5.0",
|
|
115
|
+
"stylelint": "^17.0.0"
|
|
116
|
+
},
|
|
117
|
+
"peerDependenciesMeta": {
|
|
118
|
+
"@playwright/test": {
|
|
119
|
+
"optional": true
|
|
120
|
+
},
|
|
121
|
+
"html-validate": {
|
|
122
|
+
"optional": true
|
|
123
|
+
},
|
|
124
|
+
"postcss": {
|
|
125
|
+
"optional": true
|
|
126
|
+
},
|
|
127
|
+
"stylelint": {
|
|
128
|
+
"optional": true
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"devDependencies": {
|
|
132
|
+
"@playwright/test": "~1.64.1",
|
|
133
|
+
"@types/jsdom": "^30.0.0",
|
|
134
|
+
"@types/node": "~26.6.5",
|
|
135
|
+
"browserslist": "^4.29.1",
|
|
136
|
+
"eslint": "^10.13.0",
|
|
137
|
+
"globals": "^17.0.0",
|
|
138
|
+
"html-validate": "^11.17.0",
|
|
139
|
+
"jsdom": "^30.1.3",
|
|
140
|
+
"postcss": "^8.5.32",
|
|
141
|
+
"stylelint": "^17.16.0",
|
|
142
|
+
"typescript": "~6.0.3",
|
|
143
|
+
"vitest": "~5.0.5"
|
|
144
|
+
},
|
|
145
|
+
"publishConfig": {
|
|
146
|
+
"access": "public"
|
|
147
|
+
},
|
|
148
|
+
"sideEffects": false,
|
|
149
|
+
"browserslist": [
|
|
150
|
+
"last 2 Chrome versions",
|
|
151
|
+
"last 2 ChromeAndroid versions",
|
|
152
|
+
"last 2 Edge versions",
|
|
153
|
+
"last 2 Firefox versions",
|
|
154
|
+
"last 2 FirefoxAndroid versions",
|
|
155
|
+
"last 2 Safari versions",
|
|
156
|
+
"last 2 iOS versions",
|
|
157
|
+
"last 2 Samsung versions",
|
|
158
|
+
"Firefox ESR",
|
|
159
|
+
"not dead"
|
|
160
|
+
]
|
|
161
|
+
}
|
package/src/a11y.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The accessibility sweep every app runs, in one call: each state of the app (a route, a dialog open, an error page) in every colour mode
|
|
3
|
+
// × season at WCAG 2.2 AA and AAA, then at a 320 px reflow width, at 200% text size, in forced colours and with reduced motion, and a
|
|
4
|
+
// keyboard pass (every Tab stop is visible and shows where focus is). The assertions are exported on their own for specs that need one.
|
|
5
|
+
//
|
|
6
|
+
// // e2e/a11y.spec.ts
|
|
7
|
+
// defineA11ySuite({ home: async (page) => { await page.goto('/') }, empty: openEmpty }, { aaa: 'gate' })
|
|
8
|
+
import { expect, test } from '@playwright/test'
|
|
9
|
+
import { expectAccessible } from './axe.js'
|
|
10
|
+
import { applyLook, looks, offline } from './look.js'
|
|
11
|
+
|
|
12
|
+
/** @typedef {(page: import('@playwright/test').Page) => Promise<void>} State */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The page has no horizontal scroll: nothing is wider than the viewport (WCAG 1.4.10 reflow, and 1.4.4 at a larger text size).
|
|
16
|
+
* @param {import('@playwright/test').Page} page
|
|
17
|
+
*/
|
|
18
|
+
export async function expectNoHorizontalScroll(page) {
|
|
19
|
+
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
|
20
|
+
expect(overflow, 'the page scrolls sideways by this many CSS pixels').toBeLessThanOrEqual(0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* No animation is running: with `prefers-reduced-motion: reduce` the page holds still (WCAG 2.3.3).
|
|
25
|
+
* @param {import('@playwright/test').Page} page
|
|
26
|
+
*/
|
|
27
|
+
export async function expectNoRunningAnimations(page) {
|
|
28
|
+
await page.waitForTimeout(250)
|
|
29
|
+
const running = await page.evaluate(() => document.getAnimations().filter((a) => a.playState === 'running').map((a) => (/** @type {KeyframeEffect | null} */ (a.effect)?.target?.className || 'animation')))
|
|
30
|
+
expect(running, 'animations still running under reduced motion').toEqual([])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tab through the page: every stop is visible and shows a focus indicator (an outline or a shadow), and the tab order ends (no trap).
|
|
35
|
+
* @param {import('@playwright/test').Page} page
|
|
36
|
+
* @param {{ maxStops?: number }} [options]
|
|
37
|
+
*/
|
|
38
|
+
export async function expectKeyboardFocusVisible(page, { maxStops = 120 } = {}) {
|
|
39
|
+
/** @type {string[]} */
|
|
40
|
+
const problems = []
|
|
41
|
+
let stops = 0
|
|
42
|
+
const seen = new Set()
|
|
43
|
+
for (let i = 0; i < maxStops; i++) {
|
|
44
|
+
await page.keyboard.press('Tab')
|
|
45
|
+
const stop = await page.evaluate(() => {
|
|
46
|
+
const el = document.activeElement
|
|
47
|
+
if (!el || el === document.body) return null
|
|
48
|
+
// A visually hidden control (a radio or checkbox inside its label) shows focus on its label, so that is what is checked.
|
|
49
|
+
const tiny = el.getBoundingClientRect().width < 4 || el.getBoundingClientRect().height < 4
|
|
50
|
+
const target = tiny ? (el.closest('label') ?? el.parentElement ?? el) : el
|
|
51
|
+
const box = target.getBoundingClientRect()
|
|
52
|
+
const shows = (/** @type {Element} */ node) => {
|
|
53
|
+
const style = getComputedStyle(node)
|
|
54
|
+
return (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0) || style.boxShadow !== 'none'
|
|
55
|
+
}
|
|
56
|
+
const indicator = shows(target) || (!tiny && shows(el)) || ['::before', '::after'].some((p) => {
|
|
57
|
+
const s = getComputedStyle(target, p)
|
|
58
|
+
return (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) || s.boxShadow !== 'none'
|
|
59
|
+
})
|
|
60
|
+
const label = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.getAttribute('aria-label') ? `[${el.getAttribute('aria-label')}]` : ''} "${(target.textContent ?? '').trim().slice(0, 24)}"`
|
|
61
|
+
return { label, visible: box.width > 0 && box.height > 0, indicator }
|
|
62
|
+
})
|
|
63
|
+
if (!stop) break // focus left the document: the cycle is complete
|
|
64
|
+
const key = stop.label
|
|
65
|
+
if (seen.has(key) && seen.size > 1 && i > seen.size + 1) break
|
|
66
|
+
seen.add(key)
|
|
67
|
+
stops++
|
|
68
|
+
if (!stop.visible) problems.push(`${stop.label} takes focus but is not visible`)
|
|
69
|
+
if (!stop.indicator) problems.push(`${stop.label} shows no focus indicator`)
|
|
70
|
+
}
|
|
71
|
+
expect(stops, 'the page has no keyboard stops').toBeGreaterThan(0)
|
|
72
|
+
expect(problems).toEqual([])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Record<string, State>} states Opens each state of the app (navigates, mocks, opens the dialog).
|
|
77
|
+
* @param {{ aaa?: 'gate' | 'report' | 'off' }} [options] How WCAG AAA findings are treated (default `report`; a repo moves to `gate`
|
|
78
|
+
* in the release that clears them).
|
|
79
|
+
*/
|
|
80
|
+
export function defineA11ySuite(states, { aaa = 'report' } = {}) {
|
|
81
|
+
for (const [name, open] of Object.entries(states)) {
|
|
82
|
+
test.describe(name, () => {
|
|
83
|
+
test.beforeEach(async ({ page }) => {
|
|
84
|
+
await offline(page)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
for (const { theme, season } of looks().filter((l) => l.viewport === 'desktop')) {
|
|
88
|
+
test(`WCAG 2.2 AA and AAA (${theme} · ${season})`, async ({ page }) => {
|
|
89
|
+
await page.emulateMedia({ colorScheme: /** @type {'light' | 'dark'} */ (theme), reducedMotion: 'reduce' })
|
|
90
|
+
await open(page)
|
|
91
|
+
await applyLook(page, theme, season)
|
|
92
|
+
await expectAccessible(page, { aaa })
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test('reflows at 320 CSS pixels', async ({ page }) => {
|
|
97
|
+
await page.setViewportSize({ width: 320, height: 640 })
|
|
98
|
+
await open(page)
|
|
99
|
+
await expectNoHorizontalScroll(page)
|
|
100
|
+
await expectAccessible(page, { aaa: 'off' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('holds at 200% text size', async ({ page }) => {
|
|
104
|
+
await open(page)
|
|
105
|
+
await page.addStyleTag({ content: 'html { font-size: 200% !important; }' })
|
|
106
|
+
await expectNoHorizontalScroll(page)
|
|
107
|
+
await expectAccessible(page, { aaa: 'off' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('works in forced colours', async ({ page }) => {
|
|
111
|
+
await page.emulateMedia({ forcedColors: 'active' })
|
|
112
|
+
await open(page)
|
|
113
|
+
// The browser chooses the colours in forced-colours mode, so axe's contrast arithmetic on the declared ones does not apply.
|
|
114
|
+
await expectAccessible(page, { aaa: 'off', disableRules: ['color-contrast'] })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('holds still with reduced motion', async ({ page }) => {
|
|
118
|
+
await page.emulateMedia({ reducedMotion: 'reduce' })
|
|
119
|
+
await open(page)
|
|
120
|
+
await expectNoRunningAnimations(page)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('can be used from the keyboard, and shows where focus is', async ({ page }) => {
|
|
124
|
+
await open(page)
|
|
125
|
+
await expectKeyboardFocusVisible(page)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/axe.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Accessibility assertion for Playwright: WCAG 2.2 AA is always a hard gate. WCAG AAA (enhanced contrast and so on) is
|
|
3
|
+
// `report` (attached to the test as annotations; the default while a repo still has findings) or `gate` (fails the run; a repo
|
|
4
|
+
// switches to it in the release that clears its AAA findings, which is the target) or `off`.
|
|
5
|
+
import AxeBuilder from '@axe-core/playwright'
|
|
6
|
+
import { expect, test } from '@playwright/test'
|
|
7
|
+
import { AXE_TAGS_AA, AXE_TAGS_AAA } from './index.js'
|
|
8
|
+
|
|
9
|
+
/** @param {import('axe-core').Result[]} violations */
|
|
10
|
+
function describe(violations) {
|
|
11
|
+
return violations.map((v) => ({
|
|
12
|
+
rule: v.id,
|
|
13
|
+
impact: v.impact,
|
|
14
|
+
help: v.help,
|
|
15
|
+
nodes: v.nodes.slice(0, 6).map((n) => `${n.target.join(' ')} — ${n.failureSummary?.split('\n').slice(0, 3).join(' | ')}`),
|
|
16
|
+
}))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fail on any WCAG 2.2 A/AA (or best-practice) violation. AAA findings: `aaa: 'gate'` fails on them, `'report'` (default)
|
|
21
|
+
* attaches them to the test as `aaa` annotations, `'off'` skips the AAA run.
|
|
22
|
+
* @param {import('@playwright/test').Page} page
|
|
23
|
+
* @param {{ include?: string, exclude?: string[], disableRules?: string[], aaa?: 'gate' | 'report' | 'off' }} [options]
|
|
24
|
+
*/
|
|
25
|
+
export async function expectAccessible(page, options = {}) {
|
|
26
|
+
const build = (/** @type {readonly string[]} */ tags) => {
|
|
27
|
+
let b = new AxeBuilder({ page }).withTags([...tags]).disableRules(options.disableRules ?? [])
|
|
28
|
+
if (options.include) b = b.include(options.include)
|
|
29
|
+
for (const e of options.exclude ?? []) b = b.exclude(e)
|
|
30
|
+
return b
|
|
31
|
+
}
|
|
32
|
+
const { violations } = await build(AXE_TAGS_AA).analyze()
|
|
33
|
+
expect(describe(violations), 'WCAG 2.2 AA violations').toEqual([])
|
|
34
|
+
if (options.aaa === 'off') return
|
|
35
|
+
const aaa = await build(AXE_TAGS_AAA).analyze()
|
|
36
|
+
if (options.aaa === 'gate') {
|
|
37
|
+
expect(describe(aaa.violations), 'WCAG AAA violations').toEqual([])
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
for (const v of describe(aaa.violations)) {
|
|
41
|
+
test.info().annotations.push({ type: 'aaa', description: `${v.rule}: ${v.help} (${v.nodes.length} node(s))` })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The workspace browser support promise, as a browserslist shared config. A repo uses it with
|
|
2
|
+
// "browserslist": ["extends @meddleware/frontend-config/browserslist-config"]
|
|
3
|
+
// (browserslist resolves `@scope/pkg/browserslist-config`). It drives autoprefixer, eslint-plugin-compat,
|
|
4
|
+
// stylelint-no-unsupported-browser-features and the Vite build target, so one list decides all four.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately NOT "last 2 versions": that also selects Opera Mini, KaiOS, Baidu and QQ. Every app here is a Vue SPA and the
|
|
7
|
+
// wallet apps need wallet-standard browser extensions, so none of those engines can run them; fallbacks for them would
|
|
8
|
+
// cost CSS and complexity for no working user. Newer-than-the-oldest-browser features still need @supports / progressive
|
|
9
|
+
// enhancement, which the compat linters enforce.
|
|
10
|
+
module.exports = [
|
|
11
|
+
'last 2 Chrome versions',
|
|
12
|
+
'last 2 ChromeAndroid versions',
|
|
13
|
+
'last 2 Edge versions',
|
|
14
|
+
'last 2 Firefox versions',
|
|
15
|
+
'last 2 FirefoxAndroid versions',
|
|
16
|
+
'last 2 Safari versions',
|
|
17
|
+
'last 2 iOS versions',
|
|
18
|
+
'last 2 Samsung versions',
|
|
19
|
+
'Firefox ESR',
|
|
20
|
+
'not dead',
|
|
21
|
+
]
|
package/src/eslint.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Flat ESLint config for a Vue 3 + TypeScript app or library: Vue and TypeScript recommended rules, WAI-ARIA template linting
|
|
3
|
+
// (eslint-plugin-vuejs-accessibility), browser-API compatibility against the shared browserslist (eslint-plugin-compat), and
|
|
4
|
+
// optionally the Sui chain-access boundary (@meddleware/eslint-config). A repo's eslint.config.ts becomes
|
|
5
|
+
// import { vueApp } from '@meddleware/frontend-config/eslint'
|
|
6
|
+
// export default vueApp({ name: 'treasury-ui', sui: true })
|
|
7
|
+
import { suiBoundary } from '@meddleware/eslint-config'
|
|
8
|
+
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
|
9
|
+
import { globalIgnores } from 'eslint/config'
|
|
10
|
+
import pluginCompat from 'eslint-plugin-compat'
|
|
11
|
+
import pluginVue from 'eslint-plugin-vue'
|
|
12
|
+
import pluginA11y from 'eslint-plugin-vuejs-accessibility'
|
|
13
|
+
|
|
14
|
+
/** `@meddleware/ui` components that render a native control, so the label rules see through them. */
|
|
15
|
+
export const CONTROL_COMPONENTS = Object.freeze(['UiSelect', 'UiInput', 'UiTextarea'])
|
|
16
|
+
/** `@meddleware/ui` components that render the `<label for>` for the control in their default slot. */
|
|
17
|
+
export const LABEL_COMPONENTS = Object.freeze(['UiFormField'])
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One flat-config object. Declared structurally (rather than as ESLint's own type) because ESLint's type refers to internal
|
|
21
|
+
* packages that TypeScript cannot name in a declaration file; ESLint accepts this shape.
|
|
22
|
+
* @typedef {{ name?: string, files?: (string | string[])[], ignores?: string[], rules?: Record<string, unknown>, [key: string]: unknown }} FlatConfig
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const TESTS = ['**/*.{test,spec}.{ts,tsx}', 'tests/**', 'e2e/**']
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} options
|
|
29
|
+
* @param {string} options.name Prefix for the config object names (usually the repo name).
|
|
30
|
+
* @param {boolean} [options.sui] Append the Sui chain-access boundary (apps and tool libraries; not ui, tokens or sites).
|
|
31
|
+
* @param {string[]} [options.ignores] Extra global ignores (for example `docs/api/**`).
|
|
32
|
+
* @param {string[]} [options.controlComponents] Extra components that render a native form control.
|
|
33
|
+
* @param {string[]} [options.labelComponents] Extra components that render a label.
|
|
34
|
+
* @param {FlatConfig[]} [options.extra] Extra config objects, placed before the boundary.
|
|
35
|
+
* @returns {FlatConfig[]}
|
|
36
|
+
*/
|
|
37
|
+
export function vueApp({ name, sui = false, ignores = [], controlComponents = [], labelComponents = [], extra = [] }) {
|
|
38
|
+
return /** @type {FlatConfig[]} */ (defineConfigWithVueTs(
|
|
39
|
+
{ name: `${name}/files-to-lint`, files: ['**/*.{vue,ts,mts,tsx}'] },
|
|
40
|
+
|
|
41
|
+
globalIgnores(['**/dist/**', '**/gallery-dist/**', '**/coverage/**', '**/*.d.ts', '**/test-results/**', '**/playwright-report/**', '**/.lighthouseci/**', '**/lighthouserc.cjs', ...ignores]),
|
|
42
|
+
|
|
43
|
+
...pluginVue.configs['flat/essential'],
|
|
44
|
+
...pluginA11y.configs['flat/recommended'],
|
|
45
|
+
vueTsConfigs.recommended,
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
name: `${name}/a11y-overrides`,
|
|
49
|
+
rules: {
|
|
50
|
+
// <ol role="list"> is an intentional workaround: Safari/VoiceOver drops list semantics when list-style is
|
|
51
|
+
// removed, so the explicit role stays.
|
|
52
|
+
'vuejs-accessibility/no-redundant-roles': 'off',
|
|
53
|
+
// Accept both valid label-association patterns: a label wrapping its control (nesting) or a label[for] pointing at a
|
|
54
|
+
// control[id]. UiSelect and UiInput render a native control, so a label nesting one is labelling a control.
|
|
55
|
+
'vuejs-accessibility/label-has-for': [
|
|
56
|
+
'error',
|
|
57
|
+
{ required: { some: ['nesting', 'id'] }, controlComponents: [...CONTROL_COMPONENTS, ...controlComponents] },
|
|
58
|
+
],
|
|
59
|
+
// UiFormField renders the <label for> for the control in its default slot.
|
|
60
|
+
'vuejs-accessibility/form-control-has-label': [
|
|
61
|
+
'error',
|
|
62
|
+
{ labelComponents: [...LABEL_COMPONENTS, ...labelComponents] },
|
|
63
|
+
],
|
|
64
|
+
// Underscore-prefixed args and variables are an intentional "unused" marker.
|
|
65
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
// Browser-API compatibility against the shared browserslist. Source only: tests and e2e run in Node or in the test browser.
|
|
71
|
+
name: `${name}/compat`,
|
|
72
|
+
files: ['src/**/*.{vue,ts,mts,tsx,js}'],
|
|
73
|
+
ignores: TESTS,
|
|
74
|
+
...pluginCompat.configs['flat/recommended'],
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
// Tests use deliberately loose typing for mocks and stubs.
|
|
79
|
+
name: `${name}/test-overrides`,
|
|
80
|
+
files: TESTS,
|
|
81
|
+
rules: { '@typescript-eslint/no-explicit-any': 'off' },
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
...(/** @type {any[]} */ (extra)),
|
|
85
|
+
|
|
86
|
+
...(sui ? suiBoundary() : []),
|
|
87
|
+
))
|
|
88
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// html-validate over the Vue SFC templates (via html-validate-vue): element nesting and content models (no <p> inside <ul>, no
|
|
3
|
+
// button inside a label), the a11y preset (alt text, labels, landmarks, heading order) and the Vue naming style. A repo's
|
|
4
|
+
// .htmlvalidate.mjs becomes
|
|
5
|
+
// import { htmlValidateConfig } from '@meddleware/frontend-config/html-validate'
|
|
6
|
+
// export default htmlValidateConfig()
|
|
7
|
+
// The Vue plugin is passed as an object, so it is found from THIS package however the consumer's node_modules is laid out.
|
|
8
|
+
import htmlValidateVue from 'html-validate-vue'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `@meddleware/ui` components that render a single native element, described to the validator so content models and form
|
|
12
|
+
* rules see through them (keys are lowercased tag names).
|
|
13
|
+
*/
|
|
14
|
+
export const UI_ELEMENTS = Object.freeze({
|
|
15
|
+
uibutton: { inherit: 'button' },
|
|
16
|
+
uitoolbarbutton: { inherit: 'button' },
|
|
17
|
+
uiselect: { inherit: 'select' },
|
|
18
|
+
uinotice: { inherit: 'p' },
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} [options]
|
|
23
|
+
* @param {Record<string, unknown>} [options.elements] Extra element descriptions.
|
|
24
|
+
* @param {Record<string, unknown>} [options.rules] Rule overrides.
|
|
25
|
+
* @returns {import('html-validate').ConfigData}
|
|
26
|
+
*/
|
|
27
|
+
export function htmlValidateConfig({ elements = {}, rules = {} } = {}) {
|
|
28
|
+
return {
|
|
29
|
+
plugins: [/** @type {import('html-validate').Plugin} */ (/** @type {unknown} */ (htmlValidateVue))],
|
|
30
|
+
extends: ['html-validate:recommended', 'html-validate:a11y', 'html-validate-vue:recommended'],
|
|
31
|
+
elements: ['html5', ({ ...UI_ELEMENTS, ...elements })],
|
|
32
|
+
transform: { '^.*\\.vue$': 'html-validate-vue' },
|
|
33
|
+
rules: {
|
|
34
|
+
// Vue components are written in PascalCase (<UiStatusDot>, <KeepAlive>).
|
|
35
|
+
'element-case': ['error', { style: ['lowercase', 'pascalcase'] }],
|
|
36
|
+
'element-name': ['error', { pattern: '^(?:[a-z][a-z0-9\\-._]*-[a-z0-9\\-._]*|[A-Z][A-Za-z0-9]*)$' }],
|
|
37
|
+
// role="list" on styled lists is a deliberate Safari/VoiceOver workaround: WebKit drops list semantics when list-style is
|
|
38
|
+
// removed, so the explicit role must stay (matches the eslint vuejs-accessibility/no-redundant-roles override).
|
|
39
|
+
'no-redundant-role': 'off',
|
|
40
|
+
'prefer-native-element': ['error', { exclude: ['list'] }],
|
|
41
|
+
// html-validate 11 crashes in this rule (`valueMatches` on a dynamic value) for any Vue `:href="…"` binding, and nothing here links to
|
|
42
|
+
// a `tel:` URL, so the rule has nothing to check.
|
|
43
|
+
'tel-non-breaking': 'off',
|
|
44
|
+
...rules,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default htmlValidateConfig()
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// @meddleware/frontend-config — shared frontend tooling. Each concern has its own subpath export (see package.json
|
|
3
|
+
// "exports"); this entry holds the constants the others agree on.
|
|
4
|
+
|
|
5
|
+
/** axe tags that gate a build: WCAG 2.0, 2.1 and 2.2 level A and AA, plus axe's best practices. */
|
|
6
|
+
export const AXE_TAGS_AA = Object.freeze(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'])
|
|
7
|
+
|
|
8
|
+
/** axe tags reported but never gating: WCAG AAA (enhanced contrast and so on). */
|
|
9
|
+
export const AXE_TAGS_AAA = Object.freeze(['wcag2aaa'])
|
|
10
|
+
|
|
11
|
+
/** Playwright projects for the browsers the support promise names (engines: Chromium, Gecko, WebKit; two mobile viewports). */
|
|
12
|
+
export const BROWSER_PROJECTS = Object.freeze(['chromium', 'firefox', 'webkit', 'mobile-chrome', 'mobile-safari'])
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Lighthouse CI config (free, filesystem upload): accessibility must score 100; best practices and SEO are floors; performance and
|
|
3
|
+
// bundle weight are warnings with budgets. Lighthouse CI loads its config with `require`, so a repo's file is a `lighthouserc.cjs`
|
|
4
|
+
// (Node loads the ES module through `require`); a `lighthouserc.js` in an ES-module package is silently ignored (defaults run):
|
|
5
|
+
// const { lighthouseConfig } = require('@meddleware/frontend-config/lighthouserc')
|
|
6
|
+
// module.exports = lighthouseConfig({ port: 4181, paths: ['/', '/blockchain'] })
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {number} options.port Port `vite preview` (or `vitepress preview`) serves on.
|
|
11
|
+
* @param {string[]} [options.paths] Routes to audit.
|
|
12
|
+
* @param {string} [options.startServerCommand] Command that serves the built site (default `npx vite preview`).
|
|
13
|
+
* @param {number} [options.maxScriptKb] Warn above this much JavaScript on a route.
|
|
14
|
+
* @param {number} [options.maxStylesheetKb] Warn above this much CSS on a route.
|
|
15
|
+
*/
|
|
16
|
+
export function lighthouseConfig({ port, paths = ['/'], startServerCommand, maxScriptKb = 300, maxStylesheetKb = 60 }) {
|
|
17
|
+
return {
|
|
18
|
+
ci: {
|
|
19
|
+
collect: {
|
|
20
|
+
url: paths.map((p) => `http://localhost:${port}${p}`),
|
|
21
|
+
startServerCommand: startServerCommand ?? `npx vite preview --port ${port} --strictPort`,
|
|
22
|
+
startServerReadyPattern: 'Local',
|
|
23
|
+
numberOfRuns: 1,
|
|
24
|
+
settings: { chromeFlags: '--no-sandbox', onlyCategories: ['accessibility', 'best-practices', 'seo', 'performance'] },
|
|
25
|
+
},
|
|
26
|
+
assert: {
|
|
27
|
+
assertions: {
|
|
28
|
+
'categories:accessibility': ['error', { minScore: 1 }],
|
|
29
|
+
'categories:best-practices': ['error', { minScore: 0.95 }],
|
|
30
|
+
'categories:seo': ['warn', { minScore: 0.9 }],
|
|
31
|
+
'categories:performance': ['warn', { minScore: 0.9 }],
|
|
32
|
+
'resource-summary:script:size': ['warn', { maxNumericValue: maxScriptKb * 1024 }],
|
|
33
|
+
'resource-summary:stylesheet:size': ['warn', { maxNumericValue: maxStylesheetKb * 1024 }],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
upload: { target: 'filesystem', outputDir: 'test-results/lighthouse' },
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/look.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The "looks" a page is checked in: viewport × colour mode × season. Shared by the screenshot matrix and the axe sweeps so a
|
|
3
|
+
// look added here is covered by both. Seasons come from @meddleware/design-tokens (seasons.css, `data-season` on <html>).
|
|
4
|
+
|
|
5
|
+
export const VIEWPORTS = Object.freeze({
|
|
6
|
+
phone: Object.freeze({ width: 390, height: 844 }),
|
|
7
|
+
tablet: Object.freeze({ width: 768, height: 1024 }),
|
|
8
|
+
desktop: Object.freeze({ width: 1280, height: 900 }),
|
|
9
|
+
})
|
|
10
|
+
export const THEMES = Object.freeze(['light', 'dark'])
|
|
11
|
+
export const SEASONS = Object.freeze(['none', 'spring', 'summer', 'autumn', 'winter'])
|
|
12
|
+
|
|
13
|
+
/** Desktop covers every theme × season; tablet and phone cover each theme with no season. */
|
|
14
|
+
export function looks() {
|
|
15
|
+
/** @type {{ viewport: keyof typeof VIEWPORTS, theme: string, season: string }[]} */
|
|
16
|
+
const out = []
|
|
17
|
+
for (const viewport of /** @type {(keyof typeof VIEWPORTS)[]} */ (Object.keys(VIEWPORTS))) {
|
|
18
|
+
for (const theme of THEMES) {
|
|
19
|
+
for (const season of viewport === 'desktop' ? SEASONS : ['none']) out.push({ viewport, theme, season })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Refuse every request that leaves localhost, so a run needs no network. Failed fetches are expected and are not page errors.
|
|
27
|
+
* @param {import('@playwright/test').Page} page
|
|
28
|
+
*/
|
|
29
|
+
export async function offline(page) {
|
|
30
|
+
await page.route(/^https?:\/\/(?!(localhost|127\.0\.0\.1)[:/])/, (route) => route.abort())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Put the page in a look. Uses the real colour-mode control when the page has one and is not behind a modal dialog (as a visitor would, so
|
|
35
|
+
* the control's own state matches the page); otherwise sets the attribute (galleries; VitePress, which themes with a `.dark` class).
|
|
36
|
+
* @param {import('@playwright/test').Page} page
|
|
37
|
+
* @param {string} theme `light` | `dark`
|
|
38
|
+
* @param {string} season `none` | a season name
|
|
39
|
+
*/
|
|
40
|
+
export async function applyLook(page, theme, season) {
|
|
41
|
+
const control = page.getByRole('button', { name: theme === 'dark' ? 'Dark' : 'Light', exact: true })
|
|
42
|
+
// An open modal dialog makes the rest of the page inert (its colour-mode control cannot be clicked), so the look is set directly then.
|
|
43
|
+
if ((await control.count()) && !(await page.locator('dialog[open]').count())) await control.first().click()
|
|
44
|
+
else {
|
|
45
|
+
await page.evaluate((t) => {
|
|
46
|
+
const root = document.documentElement
|
|
47
|
+
root.setAttribute('data-theme', t)
|
|
48
|
+
root.classList.toggle('dark', t === 'dark')
|
|
49
|
+
}, theme)
|
|
50
|
+
}
|
|
51
|
+
await page.evaluate((s) => {
|
|
52
|
+
const root = document.documentElement
|
|
53
|
+
if (s === 'none') root.removeAttribute('data-season')
|
|
54
|
+
else root.setAttribute('data-season', s)
|
|
55
|
+
}, season)
|
|
56
|
+
// Let colour transitions and the like finish, so a check or a screenshot sees the look and not a frame on the way to it.
|
|
57
|
+
await page.evaluate(() => Promise.all(document.getAnimations().map((a) => a.finished.catch(() => undefined))).then(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))))
|
|
58
|
+
await page.waitForTimeout(150)
|
|
59
|
+
}
|