45 rules · 5 stated gaps
The standard
Every rule this site is built against, how each one is actually met, and — at the bottom — the ones that are not fully met. A checklist that claims total coverage is worth less than one that names its edges.
These rules are not aspirational. A dependency-free auditor checks every mechanically-checkable one of them and exits non-zero on a miss, so a missing item blocks a deploy rather than becoming a to-do nobody reads.
Craft
What a visitor can see and use.
- custom-404
- A 404 in the register's voice that offers the actual catalogue and the two most recent entries, rather than a dead end.
- cta-above-fold
- The primary action sits in the homepage hero, before any section boundary.
- internal-links
- Every page carries at least two contextual onward links in its own content. The footer existing does not count.
- thank-you-page
- A confirmation page that states who reads the message, how long a reply takes, and what to do if none arrives. noindex.
- breadcrumbs
- The visible trail and the BreadcrumbList schema are generated from one array, so they cannot drift apart.
- case-studies
- Four decisions, each with a problem, what changed, the result, and a number checkable against the repository it names.
- five-faqs
- Six questions with FAQPage structured data, generated from the same array the page renders.
- response-time-promise
- A stated turnaround in the footer of every page, and again on the contact page.
- sticky-mobile-cta
- Small screens only, appears after a sentinel at the foot of the hero leaves the viewport, dismissible, and the dismissal sticks for the visit.
- maps-directions
- Honestly omitted. There are no premises, and the footer says so in words rather than quietly leaving out a map.
- real-reviews
- Present, empty, and labelled. Nobody has said anything on the record yet, and a fabricated testimonial would break the rule it satisfies.
- interactive-scroll
- A fixed accession index reports which record is in view, driven by IntersectionObserver — about the subject, not a parallax bolted on.
Discoverability
What a crawler and a share card see.
- unique-page-titles
- One entry per route in src/lib/site.ts. The auditor reads that file and fails the build on a duplicate.
- meta-descriptions
- All unique and 50–160 characters. For register entries the summary IS the description, and the database constrains it to the same bound.
- social-share-img
- A 1200×630 card composed in code from the live register, so the counts on it cannot go stale. Real alt text.
- robots-txt
- Generated from the same PRIVATE_PATHS constant the sitemap uses, never hand-written.
- sitemap
- Derived from the route config and the register, so a new entry appears automatically. Private routes opt OUT by name.
- alt-text
- Required by the TypeScript type and by a CHECK constraint, so an image with no alt cannot be stored or built. Decorative SVG is aria-hidden.
- local-schema
- CollectionPage and Person — what this actually is. Never LocalBusiness, which would be a lie to a crawler for a rich result.
Security
Enforced in the framework and the database, not remembered per page.
- hide-api-keys
- Only NEXT_PUBLIC_* reaches the client. The auditor scans every client component for a non-public env read and fails the build on one.
- purge-git-secrets
- .gitignore covered .env, .env.* and var/ in the first commit, before any secret existed. .env.example carries names, never values.
- public-db-key-only
- There is no client-side database access at all. The connection string is server-only and the database has no public network path.
- encrypt-sensitive-data
- The rate limiter records IP addresses, which are personal data, encrypted with pgcrypto. Lookup goes through a keyed fingerprint, so nothing is decrypted to make a lockout decision.
- server-side-auth
- The console verifies a session token hash against a live Postgres row in a server component, and every write route checks again. The client gate is a form.
- lock-record-access
- Row-level security ENABLED and FORCED on all four tables, with the role created NOBYPASSRLS. Setup reads both facts back and refuses to finish if either is missing.
- block-field-tampering
- The slug is re-derived from the name and a submitted one is never read; the accession comes from a database trigger; the condition is checked against the union and again by a constraint.
- secure-session-cookies
- HttpOnly, Secure in production, SameSite=Strict, path-scoped. Ten minutes for the doorway stages, eight hours for a granted session.
- hash-passwords
- scrypt at N=2^15, and verification burns a real scrypt call even when the stored hash is missing — so an unconfigured gate and a wrong answer take the same time.
- rate-limit-login
- Checked before the credential comparison, with an escalating 15/20/25/30-minute ladder held in Postgres so it survives a restart.
- parameterize-queries
- Every statement uses $1 placeholders through node-postgres. There is no string-built SQL anywhere in the repository.
- validate-all-output
- Every API route bounds its body before parsing and clips every field after. Nothing unbounded reaches the database or a log.
- escape-user-content
- dangerouslySetInnerHTML appears only on JSON.stringify of structured data built in the same file. The auditor enforces that pairing.
- trim-api-responses
- /sites.json is a projection. localPath exists in the database for correlating an entry to a folder and stops at the export boundary.
- security-headers
- HSTS, nosniff, X-Frame-Options DENY, Referrer-Policy, Permissions-Policy with every device denied, and a CSP.
- force-https
- HSTS with a two-year max-age, includeSubDomains and preload.
- scan-dependencies
- npm audit at high severity runs inside npm run verify, so a CVE blocks the same command that ships.
Design
The rules that keep it one object rather than a pile of pages.
- privacy-policy
- Written from this codebase rather than a template. Every claim is true of the repository as it stands.
- semantic-color
- Every token is named for its role — ground, ink, accent, stamp, live, partial, draft, dormant. The auditor fails on a token named for a hue.
- uncluttered-ui
- Five navigation destinations against a cap of six. The console is deliberately not among them.
- one-easing
- A single curve site-wide. Mixing curves is what makes a site feel assembled rather than designed.
- continuous-motion
- Drifting paper grain and a blinking index caret, both running while the page is idle.
- editorial-type
- Bodoni Moda for display at up to 140px with hand-tuned tracking, Public Sans for body, IBM Plex Mono for data. None on the ban list.
- reduced-motion
- prefers-reduced-motion holds the whole site still, including smooth scrolling, and restores fill-mode animations to their resting state instead of freezing them invisible.
- focus-visible
- A visible ring on every interactive element, never removed.
- skip-link
- The first tabbable element on every page, targeting main.
What is not fully met
Five of them. Listing these is the only thing that makes the section above worth reading — anyone can publish a page of ticks.
- csp-unsafe-inlineThe CSP allows inline script
- Next's App Router inlines a bootstrap script, so script-src carries 'unsafe-inline'. That defeats a meaningful share of what a CSP is for. Removing it means threading a nonce through middleware, which is real work and is not done yet. It is listed here rather than quietly tolerated.
- csp-report-onlyThe CSP is report-only until it has been watched
- The policy ships in report-only mode and reports to an endpoint on this site. It gets widened from what actually lands there and then flipped to enforcing. Widening a policy from guesswork is how a CSP becomes a policy that permits everything.
- contact-limiterThe contact form's rate limit is weaker than the console's
- The lockout ladder lives in Postgres, which is on one machine with no public network path — so the deployed contact endpoint cannot reach it and falls back to per-instance memory. That is a speed bump rather than a wall. It is acceptable for a form that sends an email and would not be acceptable for the gate, which is why the gate never uses the fallback.
- no-uploadsrestrict-file-upload passes because there is nothing to restrict
- There is no upload endpoint. Screenshots are committed to the repository as files. The rule is satisfied in the strongest possible sense — you cannot misuse a path that does not exist — but it is worth saying plainly rather than claiming credit for a control that was never built.
- analyticsAnalytics are not switched on
- The analytics component renders nothing until a measurement ID is configured. This site deliberately does not reuse the ID from another project: two sites reporting into one property produce a single blended stream that cannot be separated afterwards.
What no auditor can check
- Whether the writing is any good.
- Whether the one strong idea is actually strong.
- Whether a case study’s numbers are true.
- Whether the design is beautiful.
Those stay human judgements. Being mechanical about the mechanical part is what buys the attention to spend on these.