Skip to content

Web rules

The lacquer web stack is TypeScript + Biome + Vitest + lefthook, deployed to Vercel/Cloudflare; the framework (Next.js / Vite / a Node API) is per-project. Web jobs run on GitHub-hosted runners — there’s no Apple toolchain here.

If the component’s framework ships its own docs inside the dependency (e.g. under node_modules/<framework>/dist/docs/, as Next.js and some others do), read the relevant guide there before writing code against it. The installed major may carry breaking API changes versus your training data, and these fast-moving frameworks routinely deprecate or rename APIs — trust the vendored copy over memory and heed its deprecation notices.

The synced CI and git hooks assume these scripts exist — define them:

Script Does
typecheck tsc --noEmit
test vitest
test:coverage vitest run --coverage
build the framework build (next build, vite build, tsc, …)
docs typedoc (see Documentation)

The lacquer syncs tsconfig.base.json (strictness flags only — no framework wiring, because web stacks are heterogeneous). Your project’s tsconfig.json extends it and adds the framework-specific bits:

{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx", // or "preserve" for Next.js
"noEmit": true,
"paths": { "@/*": ["./src/*"] }
}
}

Never relax a base flag (strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noUnusedLocals/Parameters). Fix the code. Never // @ts-ignore — use // @ts-expect-error with a reason, or fix the type.

biome.json is synced (format + lint). Run ./node_modules/.bin/biome check --write . locally; CI runs ./node_modules/.bin/biome ci --error-on-warnings ..

The synced skill trees — .agents/skills, .claude/skills, .codex/skills — are ignored. They are vendored content: a project did not write them, and it cannot fix one either, because a managed file edited in place is drift. Without the ignore a lint diagnostic in any shipped skill asset turns every consuming project’s check job red, and the project’s only escape is excluding biome.json wholesale and hand-carrying a fork of it forever. That happened: 1.11.0 shipped an ad-creative HTML template carrying useIterableCallbackReturn errors and CSS warnings, and the projects that synced it had no other move. Shipped assets are linted in the lacquer, not in seventeen downstream repos.

pnpm, npm-safe by default. The synced CI and git hooks read this component’s own package.json and pick their package manager from it: a packageManager field starting with "pnpm@" gets the pnpm path below; anything else — no such field, or no package.json yet — gets plain npm (cache: npm, npm ci, npm run <script>, npm audit), same as before this profile knew pnpm existed. Nothing needs declaring anywhere else, because package.json is the one place both CI and a developer’s own corepack already have to agree.

Every real Node component in this fleet currently uses pnpm, which is why the rest of this section is written for it. Two things have to be true in each package.json, and CI depends on both:

  • a packageManager field, e.g. "packageManager": "pnpm@11.17.0". This is the only place the version is written. pnpm/action-setup reads it, and corepack reads it locally, so CI and your machine resolve the same tree by construction rather than by coincidence. A version pinned in the workflow as well would be a second source of truth, and the two drift silently.
  • a committed pnpm-lock.yaml, and no package-lock.json. CI installs with pnpm install --frozen-lockfile, the npm ci equivalent: it refuses to run when the lockfile disagrees with package.json instead of quietly fixing it up, so a dependency change that was never locked fails the PR that made it.

Migrating an existing project is pnpm import (which reads package-lock.json and preserves the resolved versions) followed by deleting the npm lockfile — not a fresh pnpm install, which re-resolves every range and turns a package-manager change into an unreviewed dependency bump. Two things bite during that migration, both of them silent:

  • Settings live in pnpm-workspace.yaml, not package.json’s pnpm field. pnpm 11 stopped reading that field and ignores it with a warning, so a setting left there does nothing while looking correct. That includes overrides.
  • Install scripts are blocked by default, which npm never did — every postinstall in the tree ran silently there. pnpm fails the install with ERR_PNPM_IGNORED_BUILDS and requires each one be named in onlyBuiltDependencies. Grant it only where the package needs it (fetching a platform binary, typically), and treat the list as a review step: each entry is a dependency allowed arbitrary code execution on every developer machine and CI runner. The state is cached in node_modules — after adding entries, a re-run of pnpm install still reports them ignored until node_modules is removed, which reads like the setting not working.

Also check vercel.json (or whatever deploys the project) for a pinned installCommand. One site had "installCommand": "npm ci", which would have kept passing CI and failed every production deploy on the lockfile that no longer exists.

Name the local binary by its path — ./node_modules/.bin/<tool> — and use no runner at all. npx <tool> silently downloads a version when the project has none installed, so a project that never added @biomejs/biome gets a green lint step run by whatever npm served that minute. One project was in exactly that state — biome.json synced, the dependency in no package.json, the Biome step passing — and only lacquer doctor noticed the check could not be running at all. @biomejs/biome and typedoc must be real devDependencies.

pnpm exec is not the fix, and neither is npx --no-install. Both prepend node_modules/.bin and then fall through to PATH, so both fail only when the tool is nowhere at all — and the case this rule exists for is the one where it is somewhere. Measured in a directory with no node_modules, on a machine with Homebrew’s biome:

Invocation Result
npx --no-install biome --version exit 0 — the global 2.5.5
pnpm exec biome --version exit 0 — the global 2.5.5
npx --no-install typedoc --version exit 0 — a cached copy under ~/.npm/_npx; --no-install doesn’t stop a previous download being reused
./node_modules/.bin/biome exit 126, naming the missing path

Only the last can’t resolve something the project never pinned. pnpm dlx is the explicit downloading verb, so it won’t happen by accident — but “not by accident” isn’t “not at all”, which is what the check needs.

--error-on-warnings is the whole gate. Plain biome ci fails on error-severity rules only, so every warning-severity rule prints and passes — including the three this config sets deliberately (noExcessiveCognitiveComplexity, noArrayIndexKey, useSemanticElements) and the ones recommended: true brings, like noNonNullAssertion. Drop the flag and a repo goes green with warnings outstanding; that was measured, not theorised. The pre-commit hook carries the same flag so local and CI agree.

Fix the code. Never disable a rule inline without explicit user approval (mirrors the core lint rule).

  • Vitest with coverage; keep meaningful thresholds in vitest.config.ts (coverage.thresholds). The strict tier targets high coverage on logic (pure functions, API handlers) — don’t chase 100% on glue/UI.
  • Co-locate *.test.ts with the source, or under src/**. Test behaviour, not implementation. For React, prefer Testing Library + user-facing queries.
  • E2E (Playwright) and accessibility (@axe-core/playwright) are project-opt-in; when present they run as their own CI job, still on a GitHub-hosted runner.

Documentation is a requirement — see core documentation rules for the standard and the relaxation mechanism. This is the TypeScript half.

Every exported symbol carries a TSDoc comment, and every link in one resolves. Both are checked by the local pre-commit hook — there is no CI-side publishing step; this is a local, pre-commit-enforced requirement only.

/**
* Loads the user's saved sessions, newest first.
*
* @param limit - Maximum number of sessions to return; omit for all of them.
* @returns The sessions, or an empty array when the user has none.
* @throws {@link UnauthorizedError} when the session token has expired.
*/
export async function loadSessions(limit?: number): Promise<Session[]>

Use {@link Symbol} rather than a plain-text type name — a link is checked by the build, prose isn’t.

The synced typedoc.json turns on validation.notDocumented, invalidLink, and treatValidationWarningsAsErrors. entryPoints is per-project: point it at the package’s real public surface; the shipped default assumes a single barrel file at src/index.ts.

Add a docs script so the local command matches CI:

{ "scripts": { "docs": "typedoc" } }

excludeInternal: true means a symbol marked @internal is omitted from the site and exempt from the documented-export rule — the right escape for something exported only for testing or cross-module wiring. Marking a genuinely public API @internal to dodge the rule is the TypeScript spelling of disabling a lint rule.

  • Never commit a real .env. Commit .env.example (and, when you want schema-validated env, a .env.schema checked with dotenvx run -- ...).
  • Public values (e.g. NEXT_PUBLIC_*) vs. server-only secrets (service-role keys, API tokens) must be clearly separated; server secrets never reach the client bundle.
  • A vendor REST secret (e.g. RevenueCat sk_…, a Supabase service-role key) lives in the deploy platform’s env (Vercel/Cloudflare project settings) and in GitHub Actions secrets for CI — never in client code or a committed file.

Some builds can’t run without env at build time — a Next.js app that statically collects page data will throw during pnpm run build if its CMS vars are unset. Name those secrets in [project].build_env and the synced CI check job declares them:

[project]
build_env = ["NEXT_PUBLIC_SANITY_PROJECT_ID", "SANITY_API_READ_TOKEN"]

renders into web-ci.yml as:

env:
NEXT_PUBLIC_SANITY_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_SANITY_PROJECT_ID }}
SANITY_API_READ_TOKEN: ${{ secrets.SANITY_API_READ_TOKEN }}

Names only — never values. The manifest is committed; the rendered form reads each from secrets, so an unset secret is empty rather than baked into a tracked file. Names are validated against the POSIX environment-name charset, because they are interpolated into workflow YAML.

This exists because its absence was expensive. With no slot for five secret names, one project’s only escape was [project].exclude on the entire web-ci.yml — opting out of the shared workflow to add five lines, then hand-carrying a full copy that drifts every time the shared one changes. If you find yourself excluding a whole managed file to add a few lines, the shared asset is missing a seam; add the seam here rather than the exclusion there.

  • Set HTTP security headers at the edge (vercel.json headers): X-Content-Type-Options: nosniff, X-Frame-Options: DENY, a Content-Security Policy where feasible; constrain CORS to known origins (never reflect * with credentials).
  • Validate and narrow every external input at the boundary (Zod or equivalent); never trust query/body/header shape. No dangerouslySetInnerHTML with unsanitised content (Biome warns — heed it).
  • Pin and scope deploy/API tokens to least privilege.

Ship semantic HTML (Biome’s useSemanticElements); every interactive control is keyboard-reachable with a visible focus ring and an accessible name; meaning is never carried by colour alone. Target WCAG 2.1 AA.

Every CI gate and where it runs before push. See core “Local checks match CI” — a new CI job adds a row here, and a hook never runs weaker than its CI twin.

CI job / step Local
checkbiome ci . pre-commit biome (--write on staged files, re-staged)
checkpnpm run typecheck pre-commit typecheck
checkpnpm run test:coverage pre-push test
checkpnpm run build pre-push build
checkturbo run <task> (monorepo) same branch in pre-commit typecheck, pre-push test / build
checkpnpm audit pre-push audit (network, so not at commit time)
./node_modules/.bin/typedoc pre-push docs; local only, no CI counterpart
No lacquer drift lacquer audit (exit 3)

One deliberate asymmetry: pre-commit runs Biome over staged files while CI runs it over the whole component. That is inherent to a staged-file hook — a file you didn’t stage can still be broken — which is why CI is the authority and the hook is the fast feedback, never the other way round.

lefthook.yml is synced — install once with pnpm exec lefthook install. It runs Biome + typecheck + a secrets scan pre-commit (each scoped to the component via lefthook’s root:), coverage + build pre-push, and enforces Conventional Commits via the shared scripts/check-commit-msg.sh (type(scope): summary).

web-ci.yml runs lint → typecheck → test (coverage) → build → dependency audit on ubuntu-latest, path-gated to the component. The audit blocks on critical advisories by default; tighten to high (and add overrides in pnpm-workspace.yaml for unfixable transitives) per project.

A component with more than one app (a Next.js site plus an admin app, a docs site, etc.) should be a pnpm workspace with a root turbo.json, not a second lacquer component — the lacquer supports one component per profile, so a second web app has no other way to get CI, hooks, or lint config.

turbo.json is project-authored, not synced — task graphs and outputs (.next/** vs dist/**, DB migrations, …) are genuinely per-project. What web-ci.yml requires is that the task names match what it invokes:

turbo.json task Runs
lint your linter (e.g. biome ci --error-on-warnings .)
typecheck tsc --noEmit
test your test runner with coverage
build the framework build

A package inside the workspace that doesn’t define one of these scripts is silently skipped by turbo when that task runs — not a CI failure, so partial adoption across a workspace’s packages is fine.

Without a turbo.json at the component root, web-ci.yml runs the single-app path unchanged (the scripts in Required package.json scripts against the one package.json).

The pre-commit typecheck and pre-push test and build hooks branch the same way, so a monorepo’s hooks cover every package its CI covers. A hook that checked only the component’s own package.json while CI checked the whole workspace would be weaker than its CI twin, which is the failure this repo keeps finding.

If the component root is itself an app, declare //# tasks

Section titled “If the component root is itself an app, declare //# tasks”

Turbo does not run tasks in the workspace root package. When the root package is a real app — a Next.js site at the repo root with apps/* beside it — a turbo.json listing only ordinary tasks runs them for apps/* and skips the site entirely, reports success, and hands CI a green check over an application nothing built:

{
"tasks": {
"//#lint": {}, // the root package — the site
"//#typecheck": {},
"//#test": {},
"//#build": { "outputs": [".next/**", "!.next/cache/**"] },
"typecheck": {}, // every other workspace package
"test": {},
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**"] }
}
}

Verify it rather than trusting it — turbo run build --dry=json lists the tasks that will actually run, and the root package appears as //#build:

Terminal window
./node_modules/.bin/turbo run build --dry=json | jq -r '.tasks[].taskId'

Every task in the table above needs checking this way. A workspace whose root holds no app (every app under apps/* or sites/*) needs no //# entries and is the simpler layout to start from.

Check what the task actually dispatches to

Section titled “Check what the task actually dispatches to”

Turbo runs the package script of the same name, so a task is only as strong as that script. Two are easy to get wrong because the single-app CI path did not use them:

  • lint must be the full biome ci --error-on-warnings ., not a narrower convenience script like biome lint src/ — the single-app path invoked Biome directly, so a weak lint script was previously never on the gate.
  • test must carry coverage. The single-app path ran test:coverage; turbo runs test.

turbo run <task> --dry=json prints each task’s command, which is the quickest way to see what is really being dispatched.

An environment variable a task reads but turbo does not know about is absent from the cache key, so a cached build is restored after the env that produced it changed. List them in globalEnv (or a task’s env):

{ "globalEnv": ["NODE_ENV", "NEXT_PUBLIC_SANITY_PROJECT_ID", "SANITY_API_READ_TOKEN"] }

Biome enforces this: noUndeclaredEnvVars reads turbo.json, so adding a turbo.json to a project makes previously-clean source fail lint until its variables are declared. That diagnostic is the cache bug, reported early.