mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
a surface guard, so the four defects cannot come back quietly
hanzo.yml already promised that "everything that needs a browser is verified against a real one". Nothing kept it. Four defects were live on two screens and were found by hand, which means the next four will be too. The guard builds the export and walks every screen a stranger can reach — the front page and the sign-in wall — at 390, 768 and 1440, asserting four things a reader is owed: no sideways scroll, every control at least 44px tall, every colour pair clearing AA, and no run of prose under 12px. Three decisions worth keeping: It serves out/ the way the bucket does, resolving a directory to its index. A test server that routed differently from hanzoai/static would pass on pages that 404 live. The contrast reading resolves each element's OWN painted background by walking up to the nearest ancestor that paints one. Comparing everything to the body's is wrong in the one direction that matters: dark text on a light button reads as 1:1 against a dark page and reports a failure that is not there. That false positive is why the reading lives in a named function instead of at each call site — it was hand-written wrong once already. The four readings are SOFT. A hard expect throws on the first failure, so a short tap target hid the type size behind it and the screen would have taken one CI run per defect to come clean. Verified by putting all four defects back: it reports tap targets and type size together, at all three widths, naming each element and its measured size. A label may be small; eight words may not. That is the line between the 11px eyebrow, which is fine, and the 11px paragraph, which was not. Not checked: line breaking. A widowed last line is a measure decision, and a rule general enough to catch one would flag prose that is fine.
This commit is contained in:
@@ -4,3 +4,7 @@ out/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
|
||||
# Surface-guard output
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// What a screen owes a reader, measured in the browser.
|
||||
//
|
||||
// This runs inside the page (page.evaluate), so it is written as one self-contained
|
||||
// function per reading: nothing here can import, and a helper defined outside the
|
||||
// returned closure would not survive serialisation.
|
||||
|
||||
export type Target = { text: string; w: number; h: number };
|
||||
export type Contrast = { text: string; px: number; ratio: number; need: number; fg: string; bg: string };
|
||||
export type Small = { text: string; px: number };
|
||||
|
||||
export type Audit = {
|
||||
scrollWidth: number;
|
||||
innerWidth: number;
|
||||
shortTargets: Target[];
|
||||
contrastFailures: Contrast[];
|
||||
smallProse: Small[];
|
||||
};
|
||||
|
||||
/** The minimum a thumb can reliably land on, in CSS px. */
|
||||
export const REACH = 44;
|
||||
|
||||
/** Below this, a run of prose is too small to read comfortably. Labels may be smaller. */
|
||||
export const PROSE_FLOOR = 12;
|
||||
|
||||
/** How many words make a run of text prose rather than a label. */
|
||||
const PROSE_WORDS = 8;
|
||||
|
||||
/**
|
||||
* audit is evaluated in the page and returns everything the assertions need in
|
||||
* one round trip.
|
||||
*
|
||||
* The contrast reading resolves each element's OWN painted background by walking
|
||||
* up until it finds one, rather than assuming the body's. Assuming the body's is
|
||||
* wrong in the one direction that matters: dark text on a light button reads as
|
||||
* 1:1 against a dark page and reports a failure that is not there. That false
|
||||
* positive is the reason this lives in a named function instead of being written
|
||||
* out at each call site.
|
||||
*/
|
||||
export function audit(): Audit {
|
||||
const luminance = (color: string) => {
|
||||
const parts = (color.match(/[\d.]+/g) ?? ['0', '0', '0']).map(Number);
|
||||
const [r, g, b] = parts.slice(0, 3).map((v) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
|
||||
const ratio = (fg: string, bg: string) => {
|
||||
const a = luminance(fg) + 0.05;
|
||||
const b = luminance(bg) + 0.05;
|
||||
return Math.round((Math.max(a, b) / Math.min(a, b)) * 100) / 100;
|
||||
};
|
||||
|
||||
// The colour a pixel actually sits on: the nearest ancestor that paints one.
|
||||
const painted = (el: Element) => {
|
||||
for (let n: Element | null = el; n; n = n.parentElement) {
|
||||
const c = getComputedStyle(n).backgroundColor;
|
||||
if (c && c !== 'transparent' && !/^rgba\(.*,\s*0\)$/.test(c)) return c;
|
||||
}
|
||||
return 'rgb(255, 255, 255)';
|
||||
};
|
||||
|
||||
const shortTargets: Target[] = [];
|
||||
const contrastFailures: Contrast[] = [];
|
||||
const smallProse: Small[] = [];
|
||||
|
||||
for (const el of document.querySelectorAll('a, button, [role="button"], input, select, textarea')) {
|
||||
const box = el.getBoundingClientRect();
|
||||
if (!box.width || !box.height) continue; // not rendered; nothing to hit
|
||||
if (box.height < 44) {
|
||||
shortTargets.push({
|
||||
text: (el.textContent ?? '').trim().slice(0, 40) || el.tagName.toLowerCase(),
|
||||
w: Math.round(box.width),
|
||||
h: Math.round(box.height),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
if (el.children.length) continue; // only the element that owns the text
|
||||
const text = (el.textContent ?? '').trim();
|
||||
if (!text) continue;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
||||
|
||||
const px = parseFloat(style.fontSize);
|
||||
const bold = parseInt(style.fontWeight, 10) >= 700;
|
||||
// WCAG's "large text" threshold, where 3:1 is the bar instead of 4.5:1.
|
||||
const large = px >= 18.66 || (px >= 14 && bold);
|
||||
const need = large ? 3 : 4.5;
|
||||
const bg = painted(el);
|
||||
const got = ratio(style.color, bg);
|
||||
if (got < need) {
|
||||
contrastFailures.push({ text: text.slice(0, 40), px, ratio: got, need, fg: style.color, bg });
|
||||
}
|
||||
|
||||
// A label may be small. A sentence may not.
|
||||
if (px < 12 && text.split(/\s+/).length >= 8) {
|
||||
smallProse.push({ text: text.slice(0, 40), px });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
shortTargets,
|
||||
contrastFailures,
|
||||
smallProse,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Serves the export the way the bucket does, so the guard tests what ships.
|
||||
//
|
||||
// hanzoai/static fronts the `tabs` bucket and resolves a directory to its
|
||||
// index.html; trailingSlash: true in next.config.mjs is what makes every route a
|
||||
// directory. This does the same two things and nothing else — a test server that
|
||||
// resolves paths differently from production would pass on pages that 404 live.
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, extname } from 'node:path';
|
||||
|
||||
const root = new URL('../out/', import.meta.url).pathname;
|
||||
const port = Number(process.env.PORT || 4321);
|
||||
|
||||
const TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.png': 'image/png',
|
||||
'.woff2': 'font/woff2',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
createServer(async (req, res) => {
|
||||
const path = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
||||
const file = extname(path) ? path : join(path, 'index.html');
|
||||
try {
|
||||
const body = await readFile(join(root, file));
|
||||
res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' });
|
||||
res.end(body);
|
||||
} catch {
|
||||
// The export has no 404 route of its own; a miss is a miss.
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('not found');
|
||||
}
|
||||
}).listen(port, () => console.log(`serving out/ on ${port}`));
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { audit, PROSE_FLOOR, REACH, type Audit } from './audit';
|
||||
|
||||
// Every screen a visitor can reach without a machine linked. The terminal panes
|
||||
// need a live machine and a signed-in identity, so they are not here; these two
|
||||
// are what a stranger sees, and both were wrong until they were measured.
|
||||
const SCREENS = [
|
||||
{ path: '/', name: 'front page' },
|
||||
{ path: '/app/', name: 'sign in' },
|
||||
];
|
||||
|
||||
for (const screen of SCREENS) {
|
||||
test.describe(screen.name, () => {
|
||||
test('fits its viewport, and every control is reachable and readable', async ({ page }, info) => {
|
||||
await page.goto(screen.path);
|
||||
// The measure depends on layout, so wait for it to settle rather than for
|
||||
// a network idle that a static export reaches before styles apply.
|
||||
await page.waitForLoadState('load');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
const a: Audit = await page.evaluate(audit);
|
||||
const where = `${screen.name} at ${info.project.name}`;
|
||||
|
||||
// SOFT, all four of them. A hard expect throws on the first failure, so a
|
||||
// short tap target would hide the contrast and the type size behind it and
|
||||
// the screen would take one CI run per defect to come clean. An audit is
|
||||
// worth having only if it reports everything it found.
|
||||
expect
|
||||
.soft(
|
||||
a.scrollWidth,
|
||||
`${where}: scrolls sideways — ${a.scrollWidth}px of content in ${a.innerWidth}px`,
|
||||
)
|
||||
.toBeLessThanOrEqual(a.innerWidth);
|
||||
|
||||
expect
|
||||
.soft(
|
||||
a.shortTargets,
|
||||
`${where}: ${a.shortTargets.length} control(s) under ${REACH}px tall — ` +
|
||||
a.shortTargets.map((t) => `"${t.text}" ${t.w}x${t.h}`).join(', '),
|
||||
)
|
||||
.toEqual([]);
|
||||
|
||||
expect
|
||||
.soft(
|
||||
a.contrastFailures,
|
||||
`${where}: ${a.contrastFailures.length} colour pair(s) below AA — ` +
|
||||
a.contrastFailures.map((c) => `"${c.text}" ${c.ratio}:1 needs ${c.need}:1`).join(', '),
|
||||
)
|
||||
.toEqual([]);
|
||||
|
||||
expect
|
||||
.soft(
|
||||
a.smallProse,
|
||||
`${where}: prose under ${PROSE_FLOOR}px — ` +
|
||||
a.smallProse.map((s) => `"${s.text}" at ${s.px}px`).join(', '),
|
||||
)
|
||||
.toEqual([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The three readings above answer "is anything wrong". This one answers "is the
|
||||
// instrument still looking", which silence cannot distinguish from the first.
|
||||
test('the audit sees the page it was pointed at', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('load');
|
||||
const a: Audit = await page.evaluate(audit);
|
||||
expect(a.innerWidth, 'no viewport width; the audit ran against nothing').toBeGreaterThan(0);
|
||||
const controls = await page.locator('a, button').count();
|
||||
expect(controls, 'the front page has links and buttons; finding none means the walk broke').toBeGreaterThan(3);
|
||||
});
|
||||
@@ -10,12 +10,22 @@
|
||||
# as "nothing ships" — it said the latter once, and cost an afternoon spent
|
||||
# looking for a lane that was already there.
|
||||
#
|
||||
# The gate is typecheck plus the jest suite. That suite is deliberately narrow
|
||||
# and says so in its own config — "pure logic only: the layout tree and the
|
||||
# shell bindings; everything that needs a browser is verified against a real
|
||||
# one" — so panes/tiles assert the split arithmetic that browser tests would
|
||||
# only observe indirectly. Between the two, a type error anywhere in app/ or
|
||||
# components/ and a layout-tree regression are both red.
|
||||
# The gate is typecheck, the jest suite, and the surface guard. Jest is
|
||||
# deliberately narrow and says so in its own config — "pure logic only: the
|
||||
# layout tree and the shell bindings; everything that needs a browser is
|
||||
# verified against a real one" — so panes/tiles assert the split arithmetic that
|
||||
# browser tests would only observe indirectly.
|
||||
#
|
||||
# The surface guard is the other half of that sentence, and until it existed
|
||||
# nothing kept it. It builds the export and walks every screen a stranger can
|
||||
# reach at 390, 768 and 1440, asserting four things a reader is owed: the page
|
||||
# does not scroll sideways, every control is at least 44px tall, every colour
|
||||
# pair clears AA against the background it actually sits on, and no run of prose
|
||||
# is under 12px. Four defects were live when it was written and it reports all of
|
||||
# them; the readings are soft so one does not hide the next.
|
||||
#
|
||||
# What it does NOT check: line breaking. A widowed last line is a measure
|
||||
# decision, and a rule general enough to catch one would flag prose that is fine.
|
||||
test:
|
||||
- name: build
|
||||
run: |
|
||||
@@ -23,3 +33,8 @@ test:
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: surface
|
||||
run: |
|
||||
set -e
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:surface
|
||||
|
||||
Generated
+63
@@ -17,6 +17,7 @@
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/jest": "^29",
|
||||
"@types/node": "^22",
|
||||
@@ -1611,6 +1612,22 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.12",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
|
||||
@@ -4703,6 +4720,52 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
|
||||
+3
-1
@@ -9,7 +9,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:surface": "next build && playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/design": "^0.4.6",
|
||||
@@ -20,6 +21,7 @@
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/jest": "^29",
|
||||
"@types/node": "^22",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Three widths, because the defects this guard exists to catch were width-bound:
|
||||
// a measure that only widowed above the phone, and tap targets that only matter
|
||||
// on one. A single viewport would have passed while two of the three were wrong.
|
||||
const PORT = 4321;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0, // a layout reading is deterministic; a retry would only hide a flake
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${PORT}`,
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: '390', use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } } },
|
||||
{ name: '768', use: { ...devices['Desktop Chrome'], viewport: { width: 768, height: 1024 } } },
|
||||
{ name: '1440', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
|
||||
],
|
||||
// Serves out/ the way the bucket does. The build has to have run: the guard
|
||||
// checks what ships, not what a dev server renders.
|
||||
webServer: {
|
||||
command: `PORT=${PORT} node e2e/serve.mjs`,
|
||||
url: `http://127.0.0.1:${PORT}/`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user