@meddleware/frontend-config 0.0.0-stage → 0.0.2

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/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
+ }
@@ -0,0 +1,62 @@
1
+ // @ts-check
2
+ // Playwright config for browser tests of a BUILT app, gallery or VitePress site. A repo's playwright.config.ts becomes
3
+ // import { definePlaywrightConfig } from '@meddleware/frontend-config/playwright'
4
+ // export default definePlaywrightConfig({ port: 4281 })
5
+ //
6
+ // Browsers: Chromium locally (fast); all five projects in CI — Chromium, Firefox, WebKit and a Pixel 7 / iPhone 14 viewport
7
+ // (Chromium and WebKit engines). Override with E2E_BROWSERS=chromium|all (or the `browsers` option).
8
+ // Screenshot capture (`npm run shots`, SHOTS=1) is for looking, not asserting: screens specs only run when asked, and then alone.
9
+ import { defineConfig, devices } from '@playwright/test'
10
+
11
+ /** @typedef {'chromium' | 'all'} BrowserSet */
12
+
13
+ /**
14
+ * @param {object} options
15
+ * @param {number} options.port Preview server port (unique per repo, so repos can run side by side).
16
+ * @param {'app' | 'gallery' | 'vitepress'} [options.kind] What is served. `app`: `npm run build` then `vite preview`.
17
+ * `gallery`: `npm run build:gallery` then `vite preview --config vite.gallery.config.ts`. `vitepress`: `vitepress preview docs`
18
+ * (the site must already be built, so the repo's `test:e2e` script builds first).
19
+ * @param {string} [options.command] Full server command; replaces the one `kind` implies.
20
+ * @param {BrowserSet} [options.browsers] Browser set; default from E2E_BROWSERS, else `all` in CI and `chromium` otherwise.
21
+ * @param {string} [options.testDir]
22
+ * @param {import('@playwright/test').PlaywrightTestConfig} [options.extra] Merged over the defaults.
23
+ */
24
+ export function definePlaywrightConfig({ port, kind = 'app', command, browsers, testDir = './e2e', extra = {} }) {
25
+ const server = {
26
+ app: `npm run build && npx vite preview --port ${port} --strictPort`,
27
+ gallery: `npm run build:gallery && npx vite preview --config vite.gallery.config.ts --port ${port} --strictPort`,
28
+ vitepress: `npx vitepress preview docs --port ${port}`,
29
+ }[kind]
30
+ const requested = browsers ?? process.env.E2E_BROWSERS ?? (process.env.CI && !process.env.SHOTS ? 'all' : 'chromium')
31
+ const all = requested === 'all'
32
+ const projects = [
33
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
34
+ ...(all
35
+ ? [
36
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
37
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
38
+ { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
39
+ { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
40
+ ]
41
+ : []),
42
+ ]
43
+ return defineConfig({
44
+ testDir,
45
+ // A screenshot run loads only the screens specs; a normal run never loads them. (Tests are attributed to the file that calls
46
+ // `test()`, which for `defineShots` is shots.js, so a `playwright test e2e/screens.spec.ts` file filter would find none.)
47
+ ...(process.env.SHOTS ? { testMatch: '**/screens.spec.ts' } : { testIgnore: ['**/screens.spec.ts'] }),
48
+ fullyParallel: true,
49
+ forbidOnly: !!process.env.CI,
50
+ retries: process.env.CI ? 1 : 0,
51
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
52
+ use: { baseURL: `http://localhost:${port}`, viewport: { width: 1280, height: 900 }, trace: 'retain-on-failure' },
53
+ webServer: {
54
+ command: command ?? server,
55
+ url: `http://localhost:${port}/`,
56
+ reuseExistingServer: !process.env.CI,
57
+ timeout: 180_000,
58
+ },
59
+ projects,
60
+ ...extra,
61
+ })
62
+ }
package/src/postcss.js ADDED
@@ -0,0 +1,7 @@
1
+ // @ts-check
2
+ // Autoprefixer over the CSS a build ships, targeting the repo's browserslist (which should extend
3
+ // `@meddleware/frontend-config/browserslist-config`). A repo's postcss.config.js becomes
4
+ // export { default } from '@meddleware/frontend-config/postcss'
5
+ import autoprefixer from 'autoprefixer'
6
+
7
+ export default { plugins: [autoprefixer()] }
package/src/shots.js ADDED
@@ -0,0 +1,33 @@
1
+ // @ts-check
2
+ // Screenshot matrix for LOOKING at the UI (not assertions): one PNG per state × viewport × theme × season, written to SHOTS_OUT
3
+ // (default test-results/shots). Excluded from `npm run test:e2e` (definePlaywrightConfig ignores screens specs unless SHOTS is
4
+ // set); nothing is compared against a baseline (baselines drift across machines and fonts). Compare runs by eye: shoot before
5
+ // and after a change and read both sets.
6
+ import { test } from '@playwright/test'
7
+ import { VIEWPORTS, applyLook, looks, offline } from './look.js'
8
+
9
+ /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} State */
10
+
11
+ /**
12
+ * Register one test per state × look. Call once, at the top level of an `e2e/screens.spec.ts`.
13
+ * @param {Record<string, State>} states
14
+ * @param {{ out?: string }} [options]
15
+ */
16
+ export function defineShots(states, options = {}) {
17
+ const out = options.out ?? process.env.SHOTS_OUT ?? 'test-results/shots'
18
+ for (const { viewport, theme, season } of looks()) {
19
+ for (const [name, go] of Object.entries(states)) {
20
+ test(`${name} · ${viewport} · ${theme} · ${season}`, async ({ page }) => {
21
+ await page.setViewportSize(VIEWPORTS[viewport])
22
+ await page.emulateMedia({ colorScheme: /** @type {'light' | 'dark'} */ (theme), reducedMotion: 'reduce' })
23
+ await offline(page)
24
+ await go(page)
25
+ await applyLook(page, theme, season)
26
+ // chromium keeps the plain name; other browsers get a suffix so runs never overwrite each other
27
+ const project = test.info().project.name
28
+ const tag = project === 'chromium' ? '' : `.${project}`
29
+ await page.screenshot({ path: `${out}/${name}.${viewport}.${theme}.${season}${tag}.png`, fullPage: true })
30
+ })
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,107 @@
1
+ // @ts-check
2
+ // Stylelint over CSS and the <style> blocks of Vue SFCs: the standard config tuned for a token-driven codebase, no hex colours,
3
+ // token-only values for colour and layering, accessibility rules (@double-great/stylelint-a11y) and browser-support linting
4
+ // against the shared browserslist (stylelint-no-unsupported-browser-features). A repo's stylelint.config.js becomes
5
+ // import { stylelintConfig } from '@meddleware/frontend-config/stylelint'
6
+ // export default stylelintConfig()
7
+ import { fileURLToPath } from 'node:url'
8
+ import postcssHtml from 'postcss-html'
9
+
10
+ // Resolve plugins and the base config from THIS package, not the consumer's directory, so they are found however the consumer's
11
+ // package manager lays out node_modules (stylelint otherwise resolves names relative to the consumer's config file).
12
+ /** @param {string} name */
13
+ const here = (name) => fileURLToPath(import.meta.resolve(name))
14
+
15
+ /** Colour-bearing properties: a literal colour here bypasses the design tokens (and so the seasons). */
16
+ const COLOUR_PROPERTIES = ['/color$/', 'fill', 'stroke', 'outline-color', 'text-decoration-color', 'caret-color', 'accent-color']
17
+
18
+ /** Values that carry no colour of their own, so they are allowed where a token is expected. */
19
+ const NEUTRAL_VALUES = ['transparent', 'inherit', 'currentColor', 'currentcolor', 'none', 'initial', 'unset', 'revert', 'auto', '0']
20
+
21
+ /**
22
+ * @param {object} [options]
23
+ * @param {'error' | 'warning' | false} [options.tokens] Severity of the token-only rule. Default `error`; a repo with
24
+ * literals still to fix starts at `warning` and moves to `error` in the same release as the fixes.
25
+ * @param {'error' | 'warning'} [options.compat] Severity of unsupported-browser-feature findings. Default `error`.
26
+ * @param {string[]} [options.browsers] Browsers to check against. Default: the repo's browserslist (the shared support promise).
27
+ * @param {Record<string, unknown>} [options.rules] Rule overrides.
28
+ */
29
+ export function stylelintConfig({ tokens = 'error', compat = 'error', browsers, rules = {} } = {}) {
30
+ return {
31
+ extends: [here('stylelint-config-standard')],
32
+ plugins: [
33
+ here('stylelint-no-unsupported-browser-features'),
34
+ here('@double-great/stylelint-a11y'),
35
+ here('stylelint-declaration-strict-value'),
36
+ ],
37
+ overrides: [{ files: ['**/*.vue', '**/*.html'], customSyntax: postcssHtml }],
38
+ rules: {
39
+ // The design system leans heavily on custom properties and deliberate ordering; these standard rules are too noisy for
40
+ // a token-driven codebase.
41
+ 'custom-property-empty-line-before': null,
42
+ 'declaration-empty-line-before': null,
43
+ 'comment-empty-line-before': null,
44
+ 'rule-empty-line-before': null,
45
+ 'at-rule-empty-line-before': null,
46
+ 'no-descending-specificity': null,
47
+ // BEM-style class names (block__element--modifier) are intentional.
48
+ 'selector-class-pattern': null,
49
+ // Some presentational SFCs use compact single-line multi-declaration rules.
50
+ 'declaration-block-single-line-max-declarations': null,
51
+ // Preserve camelCase CSS keyword values (for example text-rendering: optimizeLegibility).
52
+ 'value-keyword-case': null,
53
+ // Vue SFC scoped-style pseudo-classes.
54
+ 'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['deep', 'slotted', 'global'] }],
55
+ // Private panel vars use a leading-underscore convention (--_bg, --_text).
56
+ 'custom-property-pattern': null,
57
+ // Vendor-prefixed properties are emitted by autoprefixer at build time; the hand-written -webkit-/-moz-
58
+ // font-smoothing pair is intentional.
59
+ 'property-no-vendor-prefix': null,
60
+ 'value-no-vendor-prefix': null,
61
+
62
+ // Colours come from the design tokens, never a literal.
63
+ 'color-no-hex': true,
64
+
65
+ ...(tokens
66
+ ? {
67
+ 'scale-unlimited/declaration-strict-value': [
68
+ [...COLOUR_PROPERTIES, 'z-index'],
69
+ { ignoreValues: NEUTRAL_VALUES, disableFix: true, severity: tokens },
70
+ ],
71
+ }
72
+ : {}),
73
+
74
+ // Browser support against the shared browserslist. Partial support is accepted: the feature degrades, it does not break.
75
+ // Three more degrade harmlessly and are ignored on purpose: `resize` (touch Safari has no resizable fields), non-standard
76
+ // `cursor` values (a pointer-device nicety), and the extended system font keywords (every use has a fallback stack).
77
+ 'plugin/no-unsupported-browser-features': [
78
+ true,
79
+ {
80
+ severity: compat,
81
+ ignorePartialSupport: true,
82
+ ignore: ['css-resize', 'css3-cursors', 'extended-system-fonts'],
83
+ ...(browsers ? { browsers } : {}),
84
+ },
85
+ ],
86
+
87
+ // Accessibility.
88
+ // (`a11y/media-prefers-reduced-motion` is off: @meddleware/ui's reset applies one global reduced-motion rule to every app,
89
+ // so a per-rule media query is redundant noise.)
90
+ 'a11y/media-prefers-reduced-motion': null,
91
+ 'a11y/no-outline-none': true,
92
+ // (`a11y/selector-pseudo-class-focus` is off: it asks for a `:focus` twin of every `:hover`, but the reset gives every focusable
93
+ // element a visible `:focus-visible` ring and `a11y/no-outline-none` below stops anything removing it, so a hover-only cosmetic
94
+ // needs no twin.)
95
+ 'a11y/selector-pseudo-class-focus': null,
96
+ 'a11y/content-property-no-static-value': true,
97
+ 'a11y/font-size-is-readable': true,
98
+ 'a11y/no-obsolete-attribute': true,
99
+ 'a11y/no-obsolete-element': true,
100
+ 'a11y/no-text-align-justify': true,
101
+
102
+ ...rules,
103
+ },
104
+ }
105
+ }
106
+
107
+ export default stylelintConfig()
package/src/vite.js ADDED
@@ -0,0 +1,87 @@
1
+ // @ts-check
2
+ // Vite build target derived from the repo's browserslist, so the shipped JS and CSS are transformed for exactly the browsers the
3
+ // support promise names (instead of `esnext`, or Vite's own default). In vite.config.ts:
4
+ // import { viteTarget } from '@meddleware/frontend-config/vite'
5
+ // export default defineConfig({ build: { target: viteTarget() } })
6
+ import { readFileSync } from 'node:fs'
7
+ import { createRequire } from 'node:module'
8
+ import { join } from 'node:path'
9
+ import browserslistToEsbuild from 'browserslist-to-esbuild'
10
+
11
+ /** @param {string | string[]} [queries] Browserslist queries; default: the repo's own configuration. */
12
+ export function viteTarget(queries) {
13
+ return browserslistToEsbuild(queries)
14
+ }
15
+
16
+ /** Resolve a package file from the app's own directory, where `@meddleware/ui` and the tokens are installed. */
17
+ function resolveFromApp(/** @type {string} */ specifier) {
18
+ return createRequire(join(process.cwd(), 'noop.js')).resolve(specifier)
19
+ }
20
+
21
+ /**
22
+ * The colour mode and the browser chrome, before the first paint: a Vite plugin for an app's `index.html`.
23
+ *
24
+ * - Serves `@meddleware/ui`'s `theme-init.js` at `/theme-init.js` (dev) and emits it into the build, and loads it as the first,
25
+ * render-blocking script in `<head>`. It applies the saved (or default) colour mode and the season to `<html>` before anything
26
+ * paints, so a page never flashes the wrong theme while its JavaScript loads. It is a same-origin file, so the CSP needs no hash
27
+ * and no `'unsafe-inline'`.
28
+ * - Adds `<meta name="color-scheme" content="light dark">` (native controls and scrollbars follow the theme) and the browser-chrome
29
+ * `theme-color` for each colour mode, from the design tokens' page background.
30
+ *
31
+ * ```ts
32
+ * plugins: [vue(), themeInit({ default: 'dark' })]
33
+ * ```
34
+ *
35
+ * @param {object} [options]
36
+ * @param {'light' | 'dark' | 'system'} [options.default] The mode a first visit gets (a saved choice always wins). Default `system`.
37
+ * @param {string} [options.scriptPath] The script to serve (default: `@meddleware/ui/theme-init.js` as installed in the app).
38
+ * @param {{ light: string, dark: string }} [options.colors] The `theme-color` values (default: the tokens' page backgrounds).
39
+ * @returns {import('vite').Plugin}
40
+ */
41
+ export function themeInit({ default: mode = 'system', scriptPath, colors } = {}) {
42
+ const read = () => readFileSync(scriptPath ?? resolveFromApp('@meddleware/ui/theme-init.js'), 'utf8')
43
+ const chrome = () => {
44
+ if (colors) return colors
45
+ const { semantic } = JSON.parse(readFileSync(resolveFromApp('@meddleware/design-tokens/tokens.json'), 'utf8'))
46
+ return { light: semantic.light.bg, dark: semantic.dark.bg }
47
+ }
48
+ return {
49
+ name: 'meddleware:theme-init',
50
+ configureServer(server) {
51
+ server.middlewares.use('/theme-init.js', (_req, res) => {
52
+ res.setHeader('content-type', 'text/javascript')
53
+ res.end(read())
54
+ })
55
+ },
56
+ generateBundle() {
57
+ this.emitFile({ type: 'asset', fileName: 'theme-init.js', source: read() })
58
+ },
59
+ transformIndexHtml() {
60
+ const { light, dark } = chrome()
61
+ return [
62
+ { tag: 'script', attrs: { src: '/theme-init.js', 'data-default': mode }, injectTo: 'head-prepend' },
63
+ { tag: 'meta', attrs: { name: 'color-scheme', content: 'light dark' }, injectTo: 'head' },
64
+ { tag: 'meta', attrs: { name: 'theme-color', content: light, media: '(prefers-color-scheme: light)' }, injectTo: 'head' },
65
+ { tag: 'meta', attrs: { name: 'theme-color', content: dark, media: '(prefers-color-scheme: dark)' }, injectTo: 'head' },
66
+ ]
67
+ },
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The end-to-end build mode registers a mock wallet and a stub chain client in the app (`@meddleware/wallet-adapter/testing`), which must
73
+ * never reach a production build. The app's entry loads it only when `import.meta.env.MODE === 'e2e' && import.meta.env.VITE_E2E === '1'`
74
+ * (both static, so every other build drops the block); this makes `VITE_E2E=1` outside `--mode e2e` a build error instead of a silent no-op.
75
+ *
76
+ * ```ts
77
+ * export default defineConfig(({ mode }) => { assertE2eOnlyInE2eMode(mode, process.env); return { … } })
78
+ * ```
79
+ *
80
+ * @param {string} mode Vite's mode.
81
+ * @param {Record<string, string | undefined>} env The environment (default: the process's).
82
+ */
83
+ export function assertE2eOnlyInE2eMode(mode, env = process.env) {
84
+ if (env.VITE_E2E && mode !== 'e2e') {
85
+ throw new Error(`VITE_E2E is set but the build mode is "${mode}": the end-to-end build is \`vite build --mode e2e\`, and nothing else may include the test wallet.`)
86
+ }
87
+ }
package/src/vitest.js ADDED
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+ // Vitest settings for a repo whose code imports the Meddleware UI libraries. Those packages ship ES modules that import their own `.css`
3
+ // files (each component brings its CSS), and Node cannot import a `.css` file. Vitest runs `node_modules` dependencies through Node by
4
+ // default, so the import fails: the libraries have to go through Vite instead, which also gives them the SAME copy of Vue as the code
5
+ // under test (a second copy breaks reactivity across the boundary, which is why tests used to mock `@meddleware/ui`).
6
+ //
7
+ // // vitest.config.ts (and, in a jsdom test that renders a dialog: beforeAll(installDialogShim))
8
+ // import { inlineMeddleware } from '@meddleware/frontend-config/vitest'
9
+ // export default defineConfig({
10
+ // plugins: [vue()],
11
+ // resolve: { dedupe: ['vue', '@vue/test-utils'] },
12
+ // test: { server: { deps: { inline: inlineMeddleware } } },
13
+ // })
14
+
15
+ /**
16
+ * Packages to process through Vite in tests: the ones that import CSS or `.vue` files. Vitest matches these against the module's resolved
17
+ * file path (`…/node_modules/@meddleware/ui/dist/…`), not the package name, so the patterns match the path.
18
+ */
19
+ export const inlineMeddleware = [/[\\/]@meddleware[\\/](ui|wallet-adapter|walrus-storage-kit|design-tokens)[\\/]/]
20
+
21
+ /**
22
+ * jsdom does not implement `<dialog>` modality (`showModal`, `close`, `returnValue`). This minimal shim models the parts `UiDialog` relies on:
23
+ * the `open` attribute, `returnValue`, and a `close` event on closing. Call it once (`beforeAll(installDialogShim)`) in a jsdom test that
24
+ * renders a dialog; the browser's real behaviour (top layer, inert background) is checked by the Playwright suites, not jsdom.
25
+ */
26
+ export function installDialogShim() {
27
+ if (typeof HTMLDialogElement === 'undefined') return // a node-environment test file: there is no DOM to patch
28
+ const proto = /** @type {HTMLDialogElement & { _rv?: string }} */ (HTMLDialogElement.prototype)
29
+ if (typeof proto.showModal === 'function') return
30
+ if (!('returnValue' in proto)) {
31
+ Object.defineProperty(proto, 'returnValue', {
32
+ configurable: true,
33
+ get: /** @this {{ _rv?: string }} */ function () {
34
+ return this._rv ?? ''
35
+ },
36
+ set: /** @this {{ _rv?: string }} */ function (/** @type {string} */ v) {
37
+ this._rv = v
38
+ },
39
+ })
40
+ }
41
+ proto.showModal = /** @this {HTMLDialogElement} */ function () {
42
+ this.returnValue = ''
43
+ this.setAttribute('open', '')
44
+ }
45
+ proto.show = proto.showModal
46
+ proto.close = /** @this {HTMLDialogElement} */ function (returnValue) {
47
+ if (!this.hasAttribute('open')) return
48
+ if (returnValue !== undefined) this.returnValue = returnValue
49
+ this.removeAttribute('open')
50
+ this.dispatchEvent(new Event('close'))
51
+ }
52
+ }
package/src/wallet.js ADDED
@@ -0,0 +1,38 @@
1
+ // @ts-check
2
+ // Playwright side of the shared mock wallet (`@meddleware/wallet-adapter/testing`, registered by an app's e2e build): connect it
3
+ // through the app's own wallet dialog, or give it a signer that lives in Node, so a key never enters the page.
4
+
5
+ /** The address the mock wallet connects with (`MOCK_ADDRESS` in wallet-adapter's testing module). */
6
+ export const E2E_ADDRESS = `0x${'a'.repeat(64)}`
7
+
8
+ /**
9
+ * Connect the mock wallet as a visitor would: open the wallet dialog, choose the wallet, and wait for the dialog to close.
10
+ * @param {import('@playwright/test').Page} page
11
+ * @param {{ wallet?: string, trigger?: string | RegExp }} [options] The wallet's name in the picker; the name of the button that opens the dialog.
12
+ */
13
+ export async function connectMockWallet(page, { wallet = 'Test Wallet', trigger = 'Connect wallet' } = {}) {
14
+ await page.getByRole('button', { name: trigger }).first().click()
15
+ const dialog = page.getByRole('dialog')
16
+ await dialog.getByRole('button', { name: wallet }).click()
17
+ await dialog.waitFor({ state: 'hidden' })
18
+ }
19
+
20
+ /**
21
+ * @typedef {object} NodeSigner
22
+ * @property {(transactionJson: string, chain: string) => Promise<{ bytes: string, signature: string }>} [signTransaction]
23
+ * Build and sign a transaction (`Transaction.from(json)`); resolves base64 bytes and signature.
24
+ * @property {(message: string) => Promise<{ signature: string }>} [signPersonalMessage] Sign a personal message (base64 bytes).
25
+ */
26
+
27
+ /**
28
+ * Let the page's mock wallet sign in this Node process. Call it before `page.goto`: the functions are exposed to every page load, and
29
+ * `installE2eWallet` uses them when they exist.
30
+ * @param {import('@playwright/test').Page} page
31
+ * @param {NodeSigner} signer
32
+ * @param {{ address?: string }} [options] The address the wallet connects with: the signer's own, so the transaction's sender and its signature agree.
33
+ */
34
+ export async function exposeSigner(page, signer, { address } = {}) {
35
+ if (address) await page.addInitScript((a) => Object.assign(window, { __e2eWalletAddress: a }), address)
36
+ if (signer.signTransaction) await page.exposeFunction('__e2eSignTransaction', signer.signTransaction)
37
+ if (signer.signPersonalMessage) await page.exposeFunction('__e2eSignPersonalMessage', signer.signPersonalMessage)
38
+ }