test(frontend): lock the style invariants a QA pass keeps re-finding

Two defects in this QA pass were pure CSS drift with no test surface at all:
100vw full-bleed sections panning the document by a scrollbar's width, and
controls sitting on three different corner radii. Assert both against the
sources, so the next person who hard-codes a radius or drops an overflow-x
guard fails CI instead of failing a screenshot review.

Tests now read files off disk, so they live in their own tsc project
(tsconfig.test.json, node types) excluded from the browser build.
This commit is contained in:
loki5512344 2026-09-28 13:27:15 +02:00
parent 1c94e0ebea
commit fd0fe59fee
Signed by: boba
GPG key ID: 253067914055423B
5 changed files with 65 additions and 1 deletions

1
frontend/.gitignore vendored
View file

@ -2,3 +2,4 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
*.tsbuildinfo

View file

@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { expect, test } from 'vitest'
/**
* Style invariants. These are what a visual QA pass keeps re-finding, because nothing
* else stops them from regressing silently: a full-bleed section turning into a
* horizontal scrollbar, and controls drifting onto a corner radius different from the
* button sitting next to them. Reads the sources, so it is a rule about the CSS we ship,
* not about one browser's computed output.
*/
// vitest runs from the package root, the same place `bun run build` does.
const read = (rel: string) => readFileSync(rel, 'utf8')
const indexCss = read('src/index.css')
const landingCss = read('src/features/landing/styles/landing.css')
const button = read('src/shared/ui/Button.tsx')
const textField = read('src/shared/ui/text-field/TextField.tsx')
test('full-bleed 100vw sections cannot produce a horizontal scrollbar', () => {
expect(landingCss).toMatch(/\.lv-bleed\s*{[^}]*width:\s*100vw/)
// Clipping body alone is not enough: <html> still pans by the width the 100vw
// sections overshoot the client width by.
expect(indexCss).toMatch(/html\s*{[^}]*overflow-x:\s*clip/)
expect(indexCss).toMatch(/body\s*{[^}]*overflow-x:\s*clip/)
})
test('every control shares one corner radius', () => {
expect(indexCss).toMatch(/--radius-ctl:\s*10px/)
expect(button).toContain('rounded-ctl')
expect(textField).toContain('rounded-ctl')
// A hard-coded radius here is how the CTAs ended up at 9.6px while buttons sat at 6px.
expect(landingCss).not.toMatch(/\.lv-cta[^{]*\{[^}]*border-radius:\s*(?!var\(--radius-ctl)\d/)
})

View file

@ -22,5 +22,7 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"] "include": ["src"],
// Tests are a separate project (tsconfig.test.json): they need Node types.
"exclude": ["src/**/tests/*", "src/test"]
} }

View file

@ -2,6 +2,7 @@
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.test.json" },
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" }
] ]
} }

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
// Tests read source files off disk (the style-invariant test), so they need
// the Node types the browser build deliberately excludes.
"types": ["node", "vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/tests/*", "src/test"]
}