Published on

Misreading "Loosen the Tool" as "Lower the Bar": The Threshold Semantics Behind Frontend Quality Gates

Authors
  • avatar
    Name
    Jack Qin
    Twitter

When it comes to frontend quality, the thing most likely to go wrong isn't a lack of tools — it's that the tools get read wrong. A toolchain sits there, and every number it produces and every exception it grants carries an implicit meaning; the loosening of quality almost always starts with someone reading that meaning backwards.

This isn't going to enumerate tool configs — anyone can look those up. What I want to take apart are the two most-often-misread thresholds: whether --max-warnings 500 is an "allowance" or a "ceiling," and whether the loosened type rules in test files are an "exception" or a "standard." These two misreadings look harmless, but they're the starting point of quality rotting from the edges in. Getting the semantics of a threshold straight is worth more than adding ten more rules.

The toolchain, each in its lane: first, sort out who handles what

To avoid misreading, the first step is knowing each tool's boundary of responsibility and not letting their capabilities blur into each other:

  • ESLint — frontend policy and most code-quality rules
  • Biome — formatting, plus a small subset of lint rules
  • Vitest — unit/component tests
  • Playwright — E2E coverage
  • CI workflows — lint, typecheck, build, contract drift, secret scanning

Lint and formatting: follow the frontend rules in apps/web/eslint.config.js, follow the formatting and the Biome rule subset in biome.json; use type imports/exports consistently; keep a11y-safe markup and valid ARIA usage.

Test style: prefer user-facing assertions and accessible queries; colocate unit tests with the code under test; cover full browser flows with Playwright.

CI expectations — the current frontend-related checks are the web-lint, web-test, web-build, web-contract, and secret-scan workflows.

Test config and thresholds: the numbers have a direction

Vitest uses jsdom + src/test/setup.ts, with coverage thresholds of 80 lines / 80 functions / 80 statements / 75 branches. The pattern: query the UI by role/label/visible behavior; mock dependencies at module boundaries when needed; keep tests next to the source files.

Playwright uses apps/web/playwright.config.ts, bootstraps auth via e2e/global-setup.ts, runs Chromium and Firefox, and retains screenshots/videos/traces on failure or retry. E2E is for login, redirects, browser behavior, and multi-page flows.

These numbers look like mere config, but each has a direction — is it a floor or a ceiling? Read the direction wrong and the meaning of the entire gate inverts. The two below are the easiest to read backwards.

Two thresholds you must read in the right direction

These two get singled out because they're the most easily misread as "permission to lower the bar."

1. --max-warnings 500 is a ceiling, not a target. apps/web/package.json allows ESLint up to 500 warnings. The misreading goes like this: someone sees "it tolerates 500" and infers "so adding a few doesn't matter." But the meaning of that number is exactly the opposite — it's a buffer ceiling left for historical debt, a compromise that says "the existing warnings aren't cleaned up yet, so don't keep CI red," not "an allowance each person can add warnings against." Reading the ceiling as an allowance turns a guardrail meant to cap debt into a license to manufacture it. The correct default for new code is zero warnings.

2. Loosened type rules in test files are an exception for tests, not a standard for production code. eslint.config.js and biome.json deliberately loosen a few unsafe-type rules in test files. The loosening has a reason: building mocks and assertions in tests occasionally requires bypassing some type strictness, and forcing the types actually makes the test harder to read. But the scope of that exception is strictly confined to test files. The misreading is taking it as a signal that "this project is lax about any," and then reaching for any in application code too. Application code should still avoid any — the leniency of tests shouldn't leak an inch.

The shared structure of both misreadings is the same: taking an exception with a clearly defined scope and over-interpreting it as a global lowering of the standard. The places where the guardrail is loosened are precisely the places that most demand self-discipline.

There's also a real a11y/testing gap worth mentioning: apps/web/e2e/pages/EmailSchedulesPage.ts has an explicit comment flagging that a certain modal lacks a dialog role. The right way to handle a gap like that is for it to be seen and recorded, not silently accepted as the norm. A written "there's a gap here" comment and a gap nobody knows about are two different things on the quality ledger.

Code review checklist

Reviewers should check:

  • Does the change follow the feature-based structure rather than introducing a parallel pattern?
  • Do API calls go through the shared client and feature hooks?
  • Do types propagate cleanly, without unnecessary any or assertions?
  • Are interactive components accessible and testable by role/label?
  • Do tests align with the existing Vitest and Playwright patterns?
  • If the frontend contract changed, has schema/client drift been considered?

Counter-examples

// Counter-example 1: treating max-warnings as an allowance, casually adding new warnings
// "the ceiling is 500 anyway, a few more is fine" ❌ — the warning ceiling is a debt buffer, not an allowance for new ones
// Counter-example 2: carrying test leniency into application code
// in application code:
function parse(input: any) {
  return input.data.items
} // ❌ tests may loosen this, production may not
// Correct: use a typed boundary
function parse(input: ApiResponse): Item[] {
  return input.data.items
}
// Counter-example 3: spinning up an ad-hoc fetch when the existing API client + React Query fits
useEffect(() => {
  fetch('/api/v1/foo').then(/* ... */)
}, []) // ❌
// Correct: go through the shared client + feature hook
const { data } = useFooQuery()
// Counter-example 4: interactive UI lacking an accessible name, leaving tests with brittle selectors
<div onClick={open}></div> // ❌
// Correct
<button aria-label="Open settings" onClick={open}></button> // ✅

Other things to avoid: don't add generic code that conflicts with the current apps/web architecture; don't skip accessible labels and roles on interactive UI; don't rely on brittle DOM traversal in tests when proper roles/labels are available; don't throw React Query's raw DTO straight out of a hook (the repo already maps it for the UI); don't use brittle selectors in E2E when the product should expose accessible hooks; and don't forget that apps/web/tsconfig.json excludes some files from TypeScript compilation coverage — confirm that important application code stays on the typed path.

Putting it into practice

  1. Read thresholds in the right direction: max-warnings 500 is a debt ceiling, not an allowance for new ones; new code defaults to zero warnings.
  2. Test leniency doesn't leak out: the loosened type rules in test files belong to tests only; application code keeps avoiding any.
  3. Make a11y gaps visible: like in EmailSchedulesPage.ts, write a known gap as an explicit comment rather than silently accepting it.
  4. Stay on the existing data path: API calls always go through the shared client + feature hook, not an ad-hoc fetch.
  5. Run the review checklist item by item: structure, data path, type propagation, accessibility, test alignment, contract drift — all six, none skipped.

The transferable layer

Strip away the specific configs of ESLint and Vitest, and the genuinely transferable insight here is: every "loosening" in a quality gate carries an implicit scope, and rot always begins when someone quietly widens that scope. A ceiling read as an allowance, a test exception read as a global standard — what's wrong isn't the tool config, it's that the person reading it dropped the premise of "who, and what situation, was this loosening set up for."

Faced with any "the tool allows me to do this," instead of asking "so can I do it," first ask: what specific problem was this loosening meant to solve, how wide is its scope, and am I about to apply it outside that scope? Wherever a guardrail exists, it's easy to hold; what truly tests a team is the few places where a guardrail was deliberately removed.