@ztd-me/frontend-checks 0.0.0-stage → 0.1.0

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/README.md CHANGED
@@ -1,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # @ztd-me/frontend-checks
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Native CSS and Playwright accessibility checks. Requires Node >=22.14 and Playwright Test ^1.62.0. Version `0.1.0` is prepared for public npm publication through automatic staging and owner 2FA promotion. See [publishing](docs/publishing.md) and [CLI integration](https://github.com/zeithrold/tools/blob/main/docs/frontend-tooling.md). Consumers retain `@ztd-me/eslint@0.1.1` and its strict standards.
4
+
5
+ After promotion and verified public installation:
6
+
7
+ ```sh
8
+ pnpm add -D --save-exact @ztd-me/frontend-checks@0.1.0 @playwright/test@1.62.0
9
+ pnpm exec playwright install chromium
10
+ ```
11
+
12
+ Retain the consuming project's release-age, trust and build policy. Wait if a newly promoted release is not yet eligible. The `0.0.0-stage` placeholder for a new package is not a usable helper release.
13
+
14
+ ## CSS
15
+
16
+ ```js
17
+ // css-check.config.mjs
18
+ export default {
19
+ files: ['src/**/*.css'],
20
+ tokenFiles: ['node_modules/tailwindcss/theme.css'],
21
+ externalCustomProperties: [],
22
+ }
23
+ ```
24
+
25
+ ```sh
26
+ pnpm exec ztd-css ./css-check.config.mjs
27
+ ```
28
+
29
+ The config module is reviewed project code and executes during import. `checkCss(options)` is also exported from `@ztd-me/frontend-checks/css`. Every supplied glob must match; missing files and syntax/configuration errors fail. JSON findings print to stdout; a nonzero exit fails the gate. Under `zt check`, the CLI also writes `css.json` to `ZT_ARTIFACTS_DIR`.
30
+
31
+ Stylelint 17.15 / standard 40 provide native CSS validation; only documented Tailwind directives and `--alpha`/`--spacing` functions receive syntax allowances. Undefined `var()` references fail across the supplied files/declaration sources, even with fallbacks. Exact runtime-generated variables can be declared in `externalCustomProperties`; document their owner in the project contract. Imports are not resolved automatically. Neither Sass/Less nor embedded Vue styles are parsed by this CSS-only entrypoint.
32
+
33
+ Hex, named and CSS color-function paint literals outside custom-property definitions fail. Token definitions retain each project's values. `transparent`, `currentColor`, inheritance and URLs are allowed. This check covers declarations, not Tailwind arbitrary-value classes, inline JS styles or every possible CSS color expression. An inventory definition is not proof of cascade/theme availability; render the relevant states. No autofix rewrites token values.
34
+
35
+ ## Playwright
36
+
37
+ ```js
38
+ import { defineConfig } from '@playwright/test'
39
+ import { verificationArtifacts } from '@ztd-me/frontend-checks/playwright'
40
+
41
+ const artifacts = verificationArtifacts()
42
+ export default defineConfig({
43
+ ...artifacts,
44
+ webServer: existingWebServer,
45
+ projects: existingProjects,
46
+ use: { ...artifacts.use, baseURL: existingBaseURL },
47
+ })
48
+ ```
49
+
50
+ ```js
51
+ import { test } from '@playwright/test'
52
+ import { assertAccessible, captureState } from '@ztd-me/frontend-checks/playwright'
53
+
54
+ test('translated dialog', async ({ page }, info) => {
55
+ await page.goto('/settings')
56
+ // Establish the real dialog state using project locators and interactions.
57
+ await assertAccessible(page, info, { label: 'settings-dialog' })
58
+ await captureState(page, info, 'settings-dialog')
59
+ })
60
+ ```
61
+
62
+ `assertAccessible` uses Axe 4.13 and defaults to WCAG 2 A/AA, 2.1 AA and 2.2 AA tags. It attaches the complete scan before asserting zero violations. Optional `include` scopes a supplemental scan; optional nonempty `tags` changes the selected coverage and must be justified locally. Full-page and keyboard/focus tests remain necessary. Projects own routes, states, browser matrices, service mocks and the built-Worker server. This helper does not provision or start them.
63
+
64
+ `verificationArtifacts(root?)` provides HTML/JSON reports, test attachments, failure traces/screenshots/videos. The root defaults to `ZT_ARTIFACTS_DIR`, otherwise `.zt/browser`. Merge with existing config; preserve native server/projects/use settings. `captureState` attaches a named PNG for review without maintaining screenshot baselines.
65
+
66
+ ## Verification
67
+
68
+ ```sh
69
+ pnpm install --frozen-lockfile
70
+ pnpm exec playwright install chromium
71
+ pnpm run check
72
+ ```
73
+
74
+ Tests execute native CSS parsing/lint, real Chromium Axe/keyboard/dialog behavior, a deliberate accessibility failure, typed imports, fresh tarball installation/CLI use, and stage authorization/duplicate guards. The workspace keeps release-age/trust policy and only the previously approved exact ESLint 0.1.1 / semver 6.3.1 exceptions. Fresh consumers use strict 24-hour release age with no exceptions. After promotion, `test:registry` verifies registry integrity against the reviewed tarball and repeats consumer import/type/CLI/browser checks; a metadata lookup alone is insufficient.
@@ -0,0 +1,40 @@
1
+ # Public stage-only release
2
+
3
+ `@ztd-me/frontend-checks@0.1.0` is prepared for authorized public publication. `.github/workflows/publish-frontend.yml` runs automatically on relevant pushes to `main`. It uses pnpm 11.22.0, frozen installation, strict lint, declarations, CSS fixtures, Chromium tests and fresh packed-consumer checks on Node 22 and 24. The Node 24 job packs the tested bytes and records the immutable source SHA and checksums. A separate job verifies those checksums and source identity before staging that exact tarball. It never directly publishes or promotes a version.
4
+
5
+ The existing repository `NPM_TOKEN` secret is the authorized route. It must permit Read and write (stage only) for this package under `@ztd-me`, with Bypass 2FA disabled. Its actual scope for this new package cannot be inferred from its success publishing ESLint. If permission is missing, the package owner must supply the narrowly authorized access; the workflow fails without creating credentials or expanding rights. Organization administration rights alone do not grant package publication rights. The token is bound to the registry in memory in the stage step only; installation, checks and packing receive no token.
6
+
7
+ Before upload, the shared stage guard checks public versions and the authenticated pending-stage list. Only pnpm's structured public-metadata HTTP 404 is interpreted as a package without published versions. Missing tokens, stage-list authorization failures, network failures and malformed responses remain blockers. An existing stage is preserved and its ID recorded rather than uploading a duplicate. An ambiguous upload outcome requires owner reconciliation before retry. No manual workflow trigger, authentication selector or OIDC fallback is configured.
8
+
9
+ ## Owner review and promotion
10
+
11
+ 1. Review and merge the tested draft PR to `main` using the repository's normal owner-controlled review process.
12
+ 2. Let the automatic workflow finish. Check the Actions source SHA, `frontend-package-<SHA>` artifact, `SHA256SUMS` and `frontend-stage-result-<SHA>` receipt. Review the pending stage's package name, version and contents against that exact artifact.
13
+ 3. Promote the stage in npm's website with the owner's 2FA, or use an already-authenticated `pnpm stage approve <stage-id>` session and complete proof of presence. Do not share the credential or OTP in chat.
14
+ 4. Verify the public registry bytes and fresh consumer as described below before dispatching consumer migrations.
15
+
16
+ First-package staging creates a public `0.0.0-stage` placeholder so package settings can be accessed. This placeholder does not make `0.1.0` installable. A successful stage receipt is also not a completed public release.
17
+
18
+ ## Exact registry verification
19
+
20
+ From the reviewed source checkout, download the automatic release run's exact `frontend-package-<SHA>` artifact and verify `SHA256SUMS` and `source-commit.txt`. Once the owner has promoted `0.1.0`, run:
21
+
22
+ ```sh
23
+ pnpm exec playwright install chromium
24
+ pnpm run test:registry 0.1.0 /path/to/verified/package.tgz
25
+ ```
26
+
27
+ The script checks the exact public package/version and registry SHA-512 integrity against the reviewed tarball, then creates a fresh pnpm consumer. It installs the exact registry version, reruns frozen installation, imports both `/css` and `/playwright`, invokes `ztd-css`, checks TypeScript declarations with strict settings and executes a deliberately failing browser Axe scan that must retain the violation evidence. Browser infrastructure errors cannot satisfy that assertion.
28
+
29
+ The consumer uses `minimumReleaseAge: 1440`, `minimumReleaseAgeStrict: true` and `trustPolicy: no-downgrade` without exceptions. A newly promoted release may require waiting 24 hours before this check is eligible. Wait and retry if the policy blocks it; do not lower policy or add an exception to force publication verification. Consumers retain their own approved policies and pin the verified version:
30
+
31
+ ```sh
32
+ pnpm add -D --save-exact @ztd-me/frontend-checks@0.1.0 @playwright/test@1.62.0
33
+ pnpm install --frozen-lockfile
34
+ ```
35
+
36
+ ## Optional future Trusted Publisher
37
+
38
+ An npm trust grant would be package-specific: GitHub owner `zeithrold`, repository `tools`, workflow filename `publish-frontend.yml`, no environment, stage publishing only. The owner must configure it separately; this release does not create one. OIDC supports stage upload but cannot authenticate the pending-stage lookup. Switching requires a reviewed guard redesign and job-scoped `id-token: write`, rather than removing the token from the current job or adding a fallback path. Owner proof of presence still controls promotion.
39
+
40
+ References: [pnpm stage](https://pnpm.io/cli/stage), [npm staged publishing](https://docs.npmjs.com/staged-publishing/), [token permissions](https://docs.npmjs.com/creating-and-viewing-access-tokens/), [Trusted Publishers](https://docs.npmjs.com/trusted-publishers/).
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@ztd-me/frontend-checks",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "UNLICENSED",
6
+ "description": "Native CSS and Playwright accessibility checks for zt frontend profiles",
7
+ "engines": {
8
+ "node": ">=22.14.0"
9
+ },
10
+ "exports": {
11
+ "./css": {
12
+ "types": "./src/css.d.ts",
13
+ "import": "./src/css.mjs"
14
+ },
15
+ "./playwright": {
16
+ "types": "./src/playwright.d.ts",
17
+ "import": "./src/playwright.mjs"
18
+ }
19
+ },
20
+ "bin": {
21
+ "ztd-css": "./src/css-cli.mjs"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "docs"
27
+ ],
28
+ "dependencies": {
29
+ "@axe-core/playwright": "4.13.0",
30
+ "fast-glob": "3.3.3",
31
+ "postcss": "8.5.27",
32
+ "postcss-value-parser": "4.2.0",
33
+ "stylelint": "17.15.0",
34
+ "stylelint-config-standard": "40.0.0",
35
+ "color-name": "2.0.0"
36
+ },
37
+ "peerDependencies": {
38
+ "@playwright/test": "^1.62.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "24.12.0",
42
+ "@playwright/test": "1.62.0",
43
+ "eslint": "10.11.0",
44
+ "typescript": "6.0.3",
45
+ "@ztd-me/eslint": "0.1.1"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/zeithrold/tools.git",
50
+ "directory": "packages/frontend-checks"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public",
54
+ "registry": "https://registry.npmjs.org/"
55
+ },
56
+ "scripts": {
57
+ "lint": "eslint src scripts test playwright.config.mjs eslint.config.js --max-warnings 0",
58
+ "typecheck": "tsc -p test/types/tsconfig.json",
59
+ "test": "node --test test/*.test.mjs",
60
+ "test:browser": "playwright test",
61
+ "test:pack": "node scripts/pack-smoke.mjs",
62
+ "check": "pnpm run lint && pnpm run typecheck && pnpm test && pnpm run test:browser && pnpm run test:pack",
63
+ "test:registry": "node scripts/registry-smoke.mjs"
64
+ }
6
65
  }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { writeFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import { pathToFileURL } from 'node:url'
6
+ import { checkCss } from './css.mjs'
7
+
8
+ async function main() {
9
+ if (process.argv.length !== 3) {
10
+ throw new Error('usage: ztd-css ./css-check.config.mjs; see README for the config schema')
11
+ }
12
+ const loaded = await import(pathToFileURL(path.resolve(process.argv[2])).href)
13
+ const report = await checkCss(loaded.default)
14
+ const data = `${JSON.stringify(report, null, 2)}\n`
15
+ if (process.env.ZT_ARTIFACTS_DIR) {
16
+ await writeFile(path.join(process.env.ZT_ARTIFACTS_DIR, 'css.json'), data)
17
+ }
18
+ process.stdout.write(data)
19
+ if (report.status !== 'passed') {
20
+ process.exitCode = 1
21
+ }
22
+ }
23
+
24
+ main().catch((error) => {
25
+ process.stderr.write(`${error.message}\n`)
26
+ process.exitCode = 1
27
+ })
@@ -0,0 +1,32 @@
1
+ import { createRequire } from 'node:module'
2
+ import standard from 'stylelint-config-standard'
3
+
4
+ const require = createRequire(import.meta.url)
5
+
6
+ export const cssConfig = {
7
+ ...standard,
8
+ extends: require.resolve('stylelint-config-standard'),
9
+ rules: {
10
+ ...standard.rules,
11
+ 'at-rule-no-unknown': [
12
+ true,
13
+ {
14
+ ignoreAtRules: [
15
+ 'theme',
16
+ 'utility',
17
+ 'variant',
18
+ 'custom-variant',
19
+ 'apply',
20
+ 'reference',
21
+ 'source',
22
+ 'config',
23
+ 'plugin',
24
+ ],
25
+ },
26
+ ],
27
+ 'function-no-unknown': [
28
+ true,
29
+ { ignoreFunctions: ['--alpha', '--spacing'] },
30
+ ],
31
+ },
32
+ }
@@ -0,0 +1,77 @@
1
+ import colors from 'color-name'
2
+ import valueParser from 'postcss-value-parser'
3
+
4
+ const colorFunctions = /^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)$/i
5
+ const colorProperties = /^color$|color$|^background(?:$|-)|^border(?:$|-)|^outline(?:$|-)|shadow$|^fill$|^stroke$/i
6
+
7
+ function references(value) {
8
+ const names = []
9
+ valueParser(value).walk((node) => {
10
+ if (node.type !== 'function' || node.value !== 'var') {
11
+ return
12
+ }
13
+ const first = node.nodes.find(child => child.type !== 'space' && child.type !== 'comment')
14
+ if (first?.type === 'word' && first.value.startsWith('--')) {
15
+ names.push(first.value)
16
+ }
17
+ })
18
+ return names
19
+ }
20
+
21
+ function hasLiteralColor(value) {
22
+ let found = false
23
+ valueParser(value).walk((node) => {
24
+ if (node.type === 'function' && node.value === 'url') {
25
+ return false
26
+ }
27
+ if (node.type === 'function' && colorFunctions.test(node.value)) {
28
+ found = true
29
+ }
30
+ const literalWord = node.type === 'word'
31
+ && (/^#[\da-f]{3,8}$/i.test(node.value) || Object.hasOwn(colors, node.value.toLowerCase()))
32
+ if (literalWord) {
33
+ found = true
34
+ }
35
+ })
36
+ return found
37
+ }
38
+
39
+ // This is a closed declaration inventory, not a proof of the runtime cascade.
40
+ export function tokenWarnings(sources, definitions, options) {
41
+ const known = new Set(options.externalCustomProperties ?? [])
42
+ for (const root of [
43
+ ...sources,
44
+ ...definitions,
45
+ ]) {
46
+ root.walkDecls((declaration) => {
47
+ if (declaration.prop.startsWith('--') && !declaration.prop.includes('*')) {
48
+ known.add(declaration.prop)
49
+ }
50
+ })
51
+ root.walkAtRules('property', rule => known.add(rule.params.trim()))
52
+ }
53
+ const warnings = []
54
+ for (const root of sources) {
55
+ root.walkDecls((declaration) => {
56
+ const issue = (rule, text) => warnings.push({
57
+ file: root.source.input.file,
58
+ line: declaration.source.start.line,
59
+ column: declaration.source.start.column,
60
+ rule,
61
+ text,
62
+ })
63
+ for (const name of references(declaration.value)) {
64
+ if (!known.has(name)) {
65
+ issue('ztd/defined-custom-property', `Undefined custom property ${name}; include its declaration source`)
66
+ }
67
+ }
68
+ if (declaration.prop.startsWith('--') || !colorProperties.test(declaration.prop)) {
69
+ return
70
+ }
71
+ if (hasLiteralColor(declaration.value)) {
72
+ issue('ztd/semantic-color', 'Move literal colors into project-owned semantic custom properties')
73
+ }
74
+ })
75
+ }
76
+ return warnings
77
+ }
package/src/css.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface CssOptions {
2
+ files: string[]
3
+ cwd?: string
4
+ /** External CSS declaration sources. Imports are not resolved implicitly. */
5
+ tokenFiles?: string[]
6
+ /** Exact runtime-provided names, reviewed in the project design contract. */
7
+ externalCustomProperties?: string[]
8
+ }
9
+ export interface CssWarning {
10
+ file: string
11
+ line: number
12
+ column: number
13
+ rule: string
14
+ text: string
15
+ }
16
+ export interface CssReport {
17
+ schemaVersion: 1
18
+ status: 'passed' | 'failed'
19
+ files: string[]
20
+ warnings: CssWarning[]
21
+ }
22
+ export function checkCss(options: CssOptions): Promise<CssReport>
package/src/css.mjs ADDED
@@ -0,0 +1,53 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import glob from 'fast-glob'
5
+ import postcss from 'postcss'
6
+ import stylelint from 'stylelint'
7
+ import { cssConfig } from './css-config.mjs'
8
+ import { tokenWarnings } from './css-tokens.mjs'
9
+
10
+ async function sources(patterns, cwd) {
11
+ const matches = await Promise.all(patterns.map(async (pattern) => {
12
+ const selected = await glob(pattern, { cwd, absolute: true, onlyFiles: true, followSymbolicLinks: false })
13
+ if (!selected.length) {
14
+ throw new Error(`CSS pattern matched no files: ${pattern}`)
15
+ }
16
+ return selected
17
+ }))
18
+ const files = [
19
+ ...new Set(matches.flat()),
20
+ ]
21
+ return Promise.all(files.sort().map(async (file) => {
22
+ const code = await readFile(file, 'utf8')
23
+ return postcss.parse(code, { from: file })
24
+ }))
25
+ }
26
+
27
+ function validate(options) {
28
+ if (!options || !Array.isArray(options.files) || !options.files.length) {
29
+ throw new TypeError('CSS files must be a nonempty explicit array of paths/globs')
30
+ }
31
+ }
32
+
33
+ export async function checkCss(options) {
34
+ validate(options)
35
+ const cwd = path.resolve(options.cwd ?? process.cwd())
36
+ const roots = await sources(options.files, cwd)
37
+ const definitions = await sources(options.tokenFiles ?? [], cwd)
38
+ const lint = await stylelint.lint({ files: roots.map(root => root.source.input.file), config: cssConfig, cwd })
39
+ const warnings = lint.results.flatMap(result => result.warnings.map(warning => ({
40
+ file: result.source,
41
+ line: warning.line,
42
+ column: warning.column,
43
+ rule: warning.rule,
44
+ text: warning.text,
45
+ })))
46
+ warnings.push(...tokenWarnings(roots, definitions, options))
47
+ return {
48
+ schemaVersion: 1,
49
+ status: lint.errored || warnings.length ? 'failed' : 'passed',
50
+ files: roots.map(root => root.source.input.file),
51
+ warnings,
52
+ }
53
+ }
@@ -0,0 +1,11 @@
1
+ import type { Page, TestInfo, PlaywrightTestConfig } from '@playwright/test'
2
+ import type { AxeBuilder } from '@axe-core/playwright'
3
+ export interface AccessibilityOptions {
4
+ label?: string
5
+ /** Restricts the scan; full-page scans remain necessary for page-level checks. */
6
+ include?: string
7
+ tags?: string[]
8
+ }
9
+ export function assertAccessible(page: Page, testInfo: TestInfo, options?: AccessibilityOptions): ReturnType<AxeBuilder['analyze']>
10
+ export function captureState(page: Page, testInfo: TestInfo, label: string): Promise<void>
11
+ export function verificationArtifacts(root?: string): Pick<PlaywrightTestConfig, 'outputDir' | 'reporter' | 'use'>
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict'
2
+ import process from 'node:process'
3
+ import AxeBuilder from '@axe-core/playwright'
4
+
5
+ /** Scan after each relevant UI state is established, not only the initial route. */
6
+ export async function assertAccessible(page, testInfo, options = {}) {
7
+ if (options.tags && !options.tags.length) {
8
+ throw new TypeError('Accessibility tags must not be empty')
9
+ }
10
+ let builder = new AxeBuilder({ page }).withTags(options.tags ?? [
11
+ 'wcag2a',
12
+ 'wcag2aa',
13
+ 'wcag21aa',
14
+ 'wcag22aa',
15
+ ])
16
+ if (options.include) {
17
+ builder = builder.include(options.include)
18
+ }
19
+ const results = await builder.analyze()
20
+ await testInfo.attach(`a11y-${options.label ?? 'state'}`, {
21
+ body: JSON.stringify(results, null, 2),
22
+ contentType: 'application/json',
23
+ })
24
+ assert.equal(results.violations.length, 0, JSON.stringify(results.violations, null, 2))
25
+ return results
26
+ }
27
+
28
+ /** Screenshots are captured evidence; this helper does not maintain baselines. */
29
+ export async function captureState(page, testInfo, label) {
30
+ await testInfo.attach(label, { body: await page.screenshot({ fullPage: true }), contentType: 'image/png' })
31
+ }
32
+
33
+ /** Apply these paths to the project's existing config; retain its webServer/projects. */
34
+ export function verificationArtifacts(root = process.env.ZT_ARTIFACTS_DIR ?? '.zt/browser') {
35
+ return {
36
+ outputDir: `${root}/test-results`,
37
+ reporter: [
38
+ ['list'],
39
+ [
40
+ 'html',
41
+ { outputFolder: `${root}/playwright-report`, open: 'never' },
42
+ ],
43
+ [
44
+ 'json',
45
+ { outputFile: `${root}/playwright.json` },
46
+ ],
47
+ ],
48
+ use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' },
49
+ }
50
+ }