Design and UI kit

Design values reach the code as generated tokens, not typed numbers

colibrity.tokens-from-the-design-file - required - developer - proved by the build - one token source, generated output, a drift test that fails

Only when the build works from a new design file rather than an existing template.

Import colour, type, spacing and radius from the design file into one token source in the repository, generate the stylesheet variables from it, and never type a design value into a component by hand. Pin the built CSS to the source with a test, so a value changed on either side fails the suite instead of drifting for a month.

Without it, the button ships a hex nobody can find in the design file, and the next restyle hunts every literal by eye.

Accepted when. The repository holds one token source and a generator, the built CSS carries every token in it, a test fails when the two disagree, and a grep of the components finds no colour literal outside the token files.

Ships in wp-starter/design-tokens.

Source. Delivered reference builds, audited together, Our own delivered work (house) - DESIGN.md to generated tokens with a drift test, third build and starter; typed variables in both older builds - not public

No information is carried by colour alone

design.not-colour-alone - blocker - designer - proved by the agency - greyscale filter loses nothing, 0 colour-only distinctions

Never let colour be the only difference between two meanings. Links in body copy get an underline. Required fields get a mark or the word. Status dots get a label or a shape. Chart series get a pattern or a direct label. The active tab gets weight or a marker too. This is WCAG 1.4.1, Level A - below the AA bar the rest of this standard targets, and the first thing an audit reads.

Without it, a link inside a rich-text field looks the same as the words around it - both delivered themes strip underlines and never put them back.

Accepted when. View the key frames with a greyscale filter on: links, required markers, statuses, chart series, map keys and the active tab are all still tellable apart, with 0 distinctions left to hue alone. Keep the dated greyscale export as the evidence.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.4.1 (A), in part - https://www.w3.org/TR/WCAG21/#use-of-color

Contrast is proven in the mockup, not found in the browser

design.contrast-in-mockup - required - designer - proved by the agency - 4.5:1 body, 3:1 large text and UI, 0 pairs unmeasured

Only when the build works from a new design file rather than an existing template.

Run a contrast plugin over every text-on-background pair in the kit before handoff - buttons in every state, placeholder text, disabled text, the footer. Fixed here it costs one variable. Found in the browser it costs a design change, a rebuild and a re-review, and the developer's token lint reports the same pairs straight back to you.

Without it, a focus ring nobody measured ships at 1.48:1 on the page background, and re-colouring it after the build costs a design change and a rebuild.

Accepted when. A dated screenshot of the contrast plugin's report on the kit: 4.5:1 for body text, 3:1 for text at 24px or 19px bold and above and for interface components, and 0 pairs left unmeasured.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.4.3 (AA), in part - https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html

Every unique section and page has its own mockup

design.mockup-coverage - required - designer - proved by the agency - one named frame per section and per page

Only when the build works from a new design file rather than an existing template.

Draw every distinct section and every page type as its own named frame, desktop and mobile. A section with no mockup gets invented during the build and argued about at review. The developer builds one code block per named frame and names it after the frame, so the frame list is also the estimate.

Without it, a page type nobody drew - the archive, the 404, the mobile variant - gets guessed at during the build and was never in the estimate.

Accepted when. On the handoff date, the frame count in the file matches the sections and page types in the agreed sitemap: 0 sections and 0 page types without a named frame. Record both counts and that date in the handoff message.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 1, item 2 - not public

Every animated surface has a defined motion spec

design.motion-spec - required - designer - proved by the agency - 8 surfaces, each with trigger, duration and easing

Only when the build works from a new design file rather than an existing template.

Write a motion sheet: for each animated surface, what starts it, how long it runs in milliseconds, and which easing. "Smooth fade" is not a spec - it is eight different animations depending on who builds it. A surface this project does not have is marked not applicable rather than left blank.

Without it, the developer picks every duration himself, which is what both delivered themes did, and the client first sees the timing in a browser.

Accepted when. The handoff file carries a motion sheet naming all eight surfaces - text reveal, button hover, image reveal, card hover, preloader, page transition, the menu on desktop and mobile, and the footer - each with a trigger, a duration in ms and a named easing, or not applicable.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 2, items 1-8, in part - not public

Layer and component names are agreed with the developer

design.naming-convention - required - designer - proved by the agency - the Figma name is the name in code, 0 renames after handoff

Only when the build works from a new design file rather than an existing template.

Agree the name of every component and section frame with the developer before handoff, then keep it. That name becomes the block's slug, its folder, its template file and its root CSS class in code, and html.bem-naming requires all four to agree - so a rename after handoff is a rename in four places plus a re-review. In Figma it costs nothing.

Without it, a name agreed in the mockup is renamed after handoff, and one word changed in Figma becomes four file renames and a re-review.

Accepted when. The kickoff note lists the agreed name of every component and section frame and is countersigned by the developer. At handoff the names in the file are that same list: 0 renames between the agreed list and the delivered file.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 1, item 7, in part - not public

The design specifies every state of every interactive element

design.state-set - required - designer - proved by the agency - hover, focus, active, disabled, loading, current - all six

Only when the build works from a new design file rather than an existing template.

Draw hover, focus, active, disabled, loading and current for every button, link, input, select, card, tab and menu item. The two that always go missing are disabled and loading, and they are the two the developer ends up inventing. Focus uses the one global focus style from the kit, not a different treatment per component.

Without it, the submit button looks identical while it is sending - one delivered theme sets a loading class in JS that no stylesheet ever consumes.

Accepted when. Every interactive component in the kit has all six states present as variants, with 0 components missing one. The focus variant is identical across components and matches the kit's single focus style.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 3, items 2-7 - not public

Text over imagery carries a scrim the design specifies

design.text-over-media - required - designer - proved by the agency - 4.5:1 at the image's darkest and lightest region

Only when the build works from a new design file rather than an existing template.

Wherever text sits on a photo or a video, specify the scrim as a value - overlay colour and opacity, or gradient stops and direction. Test it against the worst photo the client will plausibly upload, not the one you chose for the mockup. With no scrim specified the developer invents one or ships none, and it fails on the first content update rather than at launch.

Without it, an editor uploads a lighter photo than the mockup's and the heading over it falls under 3:1 - a delivered hero already sits at 2.85:1.

Accepted when. The mockup names the overlay colour and opacity, or the gradient stops, for every text-over-media block. A dated export shows each block over the lightest and darkest plausible photo, body copy at 4.5:1 and display text at 3:1 in both.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.4.3 (AA), in part - https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html

The UI kit is built and published before the build starts

design.ui-kit - required - designer - proved by the agency - components, variants, variables, styles, 0 detached

Only when the build works from a new design file rather than an existing template.

Build the kit before the build does. Every repeating element is a component with its variants, and every colour, text style, spacing step and radius is a published variable or style. No mockup carries a loose hex or a detached instance. Publish the library and share the link at kickoff - without it the build side cannot see the file, and cannot settle a single rule in this section.

Without it, raw hex spreads through the stylesheets, 171 of them across 47 files in one delivered theme, and a brand tweak becomes a search and replace.

Accepted when. Colibrity shares a published library URL at kickoff. Opening it, every colour and text value used in the mockups resolves to a published variable or style, and the file holds 0 detached instances of a kit component.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 1, item 1, in part - not public

Setup

A visible focus indicator on every focusable element

a11y.focus-visible - blocker - developer - proved by the build - one global style, 3:1 against its background

Define one global focus-visible style in the base layer, not per component. Never set outline none without an equally visible replacement. Per-component focus styling leaves every unstyled control with whatever the browser happens to do.

Without it, a global focus ring hidden behind @media (hover: hover) paints nothing on a touch laptop, so the keyboard user tabs blind.

Accepted when. Tabbing through a page shows an obvious indicator on every stop, with at least a 3:1 contrast against its background, including over images and gradients.

Ships in wp-starter/focus-tokens.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 2.4.7; 1.4.11 (AA), in part - https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html

Text and interface contrast meets AA

a11y.contrast - blocker - developer - proved by the build - 4.5:1 body, 3:1 large text and UI

4.5:1 for body text, 3:1 for text at 24px or 19px bold and above, 3:1 for interface components and focus indicators. Check text sitting on images, video and gradients by hand, because scanners cannot see those.

Without it, a body token measured at 4.37:1 sits under the AA bar and ships as real running text on every page that uses it.

Accepted when. No contrast violation is reported, and text over imagery is checked manually at its worst point rather than its best.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.4.3; 1.4.11 (AA), in part - https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html

No credential ever reaches the repository or the web root

practices.no-secrets - blocker - developer - proved by the build - git-ignored, out of the deploy, 403 over HTTP

Keep every key, password, host and licence in an environment file that is git-ignored and excluded from deployment. Whatever the deploy filter does not exclude ends up served over HTTPS - a licence file has been fetched from a live site this way.

Without it, a licence key gets fetched off the live site by anyone who guesses the filename, and rotating it is the only real fix.

Accepted when. No credential appears in git history or in the deployed web root. Requesting the environment file over HTTP returns 403 or 404.

Source. CONVENTIONS.md, wp-starter, our own repository (house) - CONVENTIONS.md:76-80, in part - not public

Each language version has its own crawlable URL

crawl.language-urls - blocker - developer - proved by the build - a distinct URL per language, 0 redirects by IP or header

Only when the site serves more than one language.

Give every language its own URL - a subdirectory, a subdomain or its own domain - and let anyone open any of them directly. Never redirect on IP address or Accept-Language. Googlebot crawls from one country with one header, so a redirect means it never sees the other languages at all, and that costs a whole language index rather than a score. The switcher is links.

Without it, Googlebot arrives with one country and one header, gets bounced to French every time, and the English pages never enter the index.

Accepted when. Each language home page returns 200 at its own URL when requested with no cookie, no referrer and an Accept-Language naming a different language. 0 automatic redirects between languages. Every switcher control is an a element with an href.

Source. Google Search Central documentation, Google (vendor) - managing-multi-regional-sites#let-the-user-switch-the-page-language, in part - https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites#let-the-user-switch-the-page-language

Search Console is verified and the build team has access

crawl.search-console - required - ops - proved by the client - verified property, build team's account opens Coverage

Ask the client at kickoff for a verified Search Console property on the production domain, and for the build team to be added to it. Verify by DNS TXT record, which survives a redesign and a theme change. It is the only source for what is actually indexed today, and the URL inventory takes it as one of four inputs, so a missing property quietly removes one.

Without it, nobody can see what Google actually did with the site, so live sitemap 404s and dropped pages go unnoticed until someone complains.

Accepted when. A dated screenshot taken from the build team's own Google account with the Coverage report open on the production property. Requested at kickoff, not at launch. After go-live the same account shows the sitemap submitted, with a read date.

Source. Google Search Central documentation, Google (vendor) - search-console-start#get-started-with-search-console, in part - https://developers.google.com/search/docs/monitor-debug/search-console-start#get-started-with-search-console

Slugs are readable, hyphenated and in the page's language

crawl.url-format - required - developer - proved by the build - no ids, no dates, under 115 chars, page's language

Fix the permalink structure and the slugs before anything is published. Lower case, words separated by hyphens, no post id, no date segment, and in the language of the page it serves. This governs the content URL only: filter and pagination state deliberately live in the query string. Renaming after launch costs redirects, lost links and a re-crawl, which is the expensive fix.

Without it, an English page ends up served on a French slug, or /?cat=38 in the results, and fixing it after launch means redirects and a fresh crawl.

Accepted when. Every URL in the crawl is under 115 characters, separates words with hyphens rather than underscores, carries no numeric id or date segment, and is in the same language as the page it serves. 0 exceptions across the sampled templates.

Source. Technical SEO control grid, A third-party SEO agency, commissioned by Colibrity (client) - row 60 - not public

The old site's indexed URLs are counted before anything is built

migration.url-inventory - blocker - developer - proved by the build - crawl plus Search Console, one list

Only when the project replaces an existing live site.

Take the inventory from four sources and merge them, because no single one is complete: the old sitemap, a full crawl, Search Console's indexed pages, and analytics for anything with traffic in the last twelve months. Break the total down by language and by template. Do this before estimating, not before launching.

Without it, 2,362 old blog posts nobody counted turn up as a 404 spike a week after launch, when the old site is gone and the list cannot be rebuilt.

Accepted when. A committed URL list with a stated total, broken down by language and template, dated, and naming which of the four sources each URL came from. Any section whose count nobody can explain is treated as unknown rather than as zero.

Source. Google Search Central documentation, Google (vendor) - site-move-with-url-changes#determine-your-current-urls, in part - https://developers.google.com/search/docs/crawling-indexing/site-move-with-url-changes#determine-your-current-urls

Field definitions live in the repository, and a field name is the data

admin.fields-in-code - required - developer - proved by the build - registered in code; no name renamed without a migration

Register field groups in code under version control, not in the admin UI, so a field change arrives as a reviewable diff and deploys with the release rather than with a database. Then treat the field name and key as the identity of the data. Labels, order and tab titles change freely. A name does not: renaming one orphans every value saved under it.

Without it, an editor opens the page after a rename and the content is simply gone, the old values still sitting under a key nothing reads.

Accepted when. Every field group the site depends on is defined in a file in the repository, and a fresh database gets them from that file with nothing imported by hand. No field name in this release differs from the last one without a migration beside it.

Source. Delivered reference builds, audited together, Our own delivered work (house) - field groups registered in PHP, both builds - not public

Production personal data does not travel to staging or a laptop

env.no-production-data-off-production - blocker - developer - proved by the build - submission and consent rows dropped or synthesised

Only when the project has a form that collects data from a visitor.

Make the database pull drop or synthesise every table holding personal data - form submissions, consent records, orders, customer accounts - inside the pull itself, not as a step someone remembers. GDPR Article 32 requires security appropriate to the risk; using fictitious or anonymised data in test environments is CNIL guidance, not black-letter law. The blocker is ours.

Without it, real form submissions with names, emails and IP addresses end up in plaintext dumps on a laptop and in a backups folder nobody prunes.

Accepted when. Row counts on staging and local for the submission, consent, order and non-staff user tables are zero against production's. The anonymisation runs inside the committed sync script, not from a runbook step.

Source. Guide de la securite des donnees personnelles - fiche "Encadrer les developpements informatiques", CNIL (Commission nationale de l'informatique et des libertes) (regulator) - Fiche: Encadrer les developpements informatiques, in part - https://www.cnil.fr/fr/securite-encadrer-les-developpements-informatiques

Environment switches are enforced in code, not in the database

env.env-config-not-in-db - required - developer - proved by the build - survives a database push in either direction

Decide indexing, tracking and debug from an environment constant, with a filter that overrides whatever the database holds. A search-visibility checkbox is a database row, and a database sync carries rows between environments in both directions, which is how a staging site silently becomes indexable and a production site silently becomes noindex.

Without it, the next content push from production overwrites the one database row that was keeping staging's tracking switched off.

Accepted when. Pushing a production database onto staging leaves staging's next response firing zero analytics requests with debug output off, and each host still reports its own environment from a server-side constant. Indexability is graded by seo.no-noindex-leak.

Source. The Twelve-Factor App, Salesforce (Heroku), written by Adam Wiggins (vendor) - III. Config, in part - https://12factor.net/config

Staging matches production where the checks depend on it

env.staging-parity - required - ops - proved by the build - same PHP, server, compression and cache headers

Put staging on the same PHP branch, the same web server and the same compression and cache configuration as production, and write down every difference that remains. Compression and cache headers are per-domain on shared hosting and off by default on a new one, so a performance or caching result measured on staging otherwise describes staging and nothing else.

Without it, staging answers on a different web server with different cache headers, so a speed or caching result signed off there describes nothing live.

Accepted when. PHP major and minor, server software, Content-Encoding on a built asset and Cache-Control on an HTML response are identical on both hosts, or every remaining difference is named in a dated parity note in the handover.

Source. The Twelve-Factor App, Salesforce (Heroku), written by Adam Wiggins (vendor) - X. Dev/prod parity, in part - https://12factor.net/dev-prod-parity

Staging never writes to a production system

env.staging-sends-nothing-live - required - developer - proved by the build - no beacon, no live CRM, no mail to a real address

Only when the project has a form that collects data from a visitor.

Gate every outbound integration on the environment: analytics and tag containers, the CRM endpoint, transactional mail, payment keys. Staging exists to be broken, and a staging site pointed at the client's real property poisons a dataset nobody can clean, or mails a real customer from a form test. No component covers the mail and CRM half - gate those yourself.

Without it, a form test on staging lands in the client's real public inbox, and test traffic lands in the analytics property they report on.

Accepted when. One staging page load fires zero requests to an analytics, tag or advertising endpoint. One staging form submission arrives in a test inbox and creates no record in the production CRM.

Source. The Twelve-Factor App, Salesforce (Heroku), written by Adam Wiggins (vendor) - IV. Backing services, in part - https://12factor.net/backing-services

Backups cover the database and the uploads, on a stated schedule

ops.backup-coverage - required - ops - proved by the agency - daily database, weekly files, one copy off-account

Turn backups on before content entry starts, not before launch: the database daily, the uploads weekly, retention stated as a number of days. Keep one copy outside the hosting account, because a suspended or deleted account takes the panel's own backups with it. Write down where they live and who is able to restore them.

Without it, the newest restorable copy on the box turns out to be a month old, and the uploads have no copy at all.

Accepted when. A dated screenshot of the backup listing shows a database backup under 24 hours old and a file backup under 7 days old. The handover states retention in days and names the copy held outside the hosting account.

Source. Hardening WordPress, WordPress (vendor) - security/hardening#data-backups, in part - https://developer.wordpress.org/advanced-administration/security/hardening/#data-backups

No write to a live database without a fresh dump of what it replaces

ops.db-write-backup - required - developer - proved by the build - dump the target first, size-checked, undo printed

Any command that overwrites a live database - a content push, a core or plugin update, a search-replace - dumps the target first, checks the dump is a real dump rather than a zero-byte gzip, and prints the exact command that puts it back. Use the project's own sync tooling, never a raw import piped at the server by hand.

Without it, the dump taken before a push turns out to be a zero-byte gzip, and the only thing printed afterwards is a placeholder nobody can run.

Accepted when. Every path in the repository that writes to a remote database dumps it first, stops when that dump fails a size and gzip check, and prints the restore command. The dump from the last production write is on disk, dated.

Source. Hardening WordPress, WordPress (vendor) - security/hardening#restricting-database-user-privileges, in part - https://developer.wordpress.org/advanced-administration/security/hardening/#restricting-database-user-privileges

A fatal error reaches a mailbox somebody actually reads

ops.fatal-alerts - required - ops - proved by the client - admin address on the client's domain, not a dev's

Point the site's admin email at a monitored mailbox on the client's or the agency's domain, and name in the handover the person who reads it. WordPress sends the fatal-error notice and the recovery link to that address, so pointed at a developer's personal mailbox it goes quiet the day the engagement ends.

Without it, the recovery link for a white-screened site goes to an address that routes nowhere, so the client finds the outage first.

Accepted when. The site's stored admin email is on the client's or the agency's own domain, is not the developer of record's address, and the handover names the person who reads that mailbox and the date they took it on.

Source. WordPress Developer Resources, WordPress (vendor) - wordpress/common-errors#critical-error-message, in part - https://developer.wordpress.org/advanced-administration/wordpress/common-errors/#critical-error-message

No REST route writes without an authorisation check or a stated bound

security.rest-route-authorisation - blocker - developer - proved by the build - no open write route without a stated bound

Give every custom route a permission callback that checks a capability. Where a route must genuinely stay public - a form submit, a nonce fetch, a violation collector - list it in the handover and name what bounds the abuse: nonce, honeypot, captcha, a hard cap on what gets stored. Capability checks elsewhere in the theme prove nothing about these routes, so read the routes themselves.

Without it, a custom route left open takes whatever a stranger posts straight into the database, with nothing capping how much lands there.

Accepted when. Every register_rest_route in the repository either passes a capability check or appears in the handover's public-route list with its bound named. No route that returns true unconditionally writes to the database without at least one named bound.

Source. REST API Handbook, WordPress (vendor) - rest-api/extending/adding-custom-endpoints#permissions-callback, in part - https://developer.wordpress.org/rest-api/extending/adding-custom-endpoints/#permissions-callback

A Content-Security-Policy is enforced, not only reported

security.csp-enforced-baseline - required - developer - proved by the build - 4 directives enforced, the rest report-only

Ship a report-only policy from the first staging deploy and read what it collects. Before go-live send a second, enforcing header carrying object-src 'none', base-uri 'self', form-action 'self' and a frame-ancestors value. Those four cost nothing - inline GSAP, JSON-LD and analytics loaders do not touch them. Leave the script and style allowlists report-only until real traffic proves them.

Without it, a report-only header watches an injected script send the form data elsewhere and does nothing but log it.

Accepted when. curl -sI returns both header names on the same response, and the enforcing one contains all four directives. Clicking through every template with the console open produces zero violations attributed to the enforcing header.

Source. Content Security Policy Level 3, W3C (standard) - ยง3.2, in part - https://www.w3.org/TR/CSP3/#cspro-header

Every HTML response carries the baseline security headers

security.response-headers - required - developer - proved by the build - 5 response headers, HSTS max-age 1 year

Send X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN or an equivalent frame-ancestors, Referrer-Policy: strict-origin-when-cross-origin, a Permissions-Policy denying geolocation, camera and microphone, and Strict-Transport-Security at a year over HTTPS only. They ship from a mu-plugin the deploy syncs separately, so one skipped step drops all five with no other symptom.

Without it, the site can be framed on someone else's page, referrers leak full URLs to third parties, and browsers keep retrying over http.

Accepted when. curl -sI on the home page and two inner pages returns all five headers with those values. Strict-Transport-Security carries max-age=31536000 and appears over https only, never on http or local. Where a page cache is in use, a response marked HIT carries them too.

Ships in wp-starter/security-headers.

Source. OWASP Secure Headers Project, OWASP Foundation (standard) - Best Practices - Configuration proposal, in part - https://github.com/OWASP/www-project-secure-headers/blob/master/mainsite/03_best_practices.md#configuration-proposal

WordPress's default attack surface is closed at setup

security.wp-attack-surface - required - ops - proved by the build - XML-RPC, author enum and file editing closed

Turn XML-RPC off unless something named actually needs it, block author enumeration through the ?author=N redirect and the anonymous users route, and set DISALLOW_FILE_EDIT so nobody edits theme or plugin files from the dashboard. Each is one filter or one constant, and each closes a class of automated attack that starts arriving within hours of a domain resolving.

Without it, a bot reads a real login name off /wp-json/wp/v2/users and gets a second unwatched door at xmlrpc.php that nothing disables.

Accepted when. /xmlrpc.php returns 403 or 405, /?author=1 does not redirect to an author archive, /wp-json/wp/v2/users returns 401 to an anonymous request, and wp config get DISALLOW_FILE_EDIT returns true.

Source. Hardening WordPress, WordPress (vendor) - security/hardening#disable-file-editing, in part - https://developer.wordpress.org/advanced-administration/security/hardening/#disable-file-editing

Markup and structure

A working skip link is the first focusable element

a11y.skip-link - required - developer - proved by the build

Put a skip link at the top of the body pointing at the main landmark's id. Hidden until focused, clearly visible when focused.

Without it, every keyboard visitor re-tabs the full header and nav on every single page before reaching the content they came for.

Accepted when. The first Tab press on a fresh page load reveals the skip link, and activating it moves focus into main so the next Tab lands on page content, not back in the header.

Ships in wp-starter/skip-link.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 2.4.1 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/bypass-blocks.html

Every form control has a real label

a11y.form-labels - blocker - developer - proved by the build - a real label element, never a placeholder

Use a label element associated with its control, not a placeholder and not an aria-label standing in for one. A placeholder vanishes as someone types, which is exactly when they need it. Mark required fields both ways, in the label text and with the required attribute, and say once whether the marker means required or optional. Add autocomplete tokens on identity and address fields.

Without it, someone using voice control has no visible text to say, and a half-filled form gives back no clue what each box was for.

Accepted when. Every input, select and textarea has an associated label element that is visible. Placeholders, where present, are supplementary rather than the only name. Required fields are marked in the label and in the markup, and the form says which convention it uses.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 3.3.2; 1.3.1; 1.3.5 (A, AA), in part - https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions.html

Icons are hidden when decorative and named when they act

a11y.icon-semantics - blocker - developer - proved by the build - decorative icons unannounced, acting icons named

Give every decorative icon aria-hidden="true" so it is skipped. An icon that is itself the control, like a bare icon button or an icon-only link, needs a name in text: a visually hidden label or an aria-label. Export tools leave a title element inside the SVG, and an unhidden icon then announces a name nobody wrote. An inline SVG has no alt attribute, so the alt-text rule does not reach it.

Without it, a bare icon button announces as just button, and a decorative tick gets read out on every badge it appears in.

Accepted when. Zero icon-only controls in the sampled templates lack an accessible name, every decorative icon is hidden from the accessibility tree, and no icon announces a leftover title from the export tool.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.1.1; 4.1.2 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html

Tap targets are big enough and far enough apart

a11y.target-size - required - developer - proved by the build - 24x24 CSS px minimum, or 24 px of spacing

Make every tap target at least 24 by 24 CSS pixels, or leave 24 pixels of clear space around a smaller one. That is WCAG 2.2 SC 2.5.8 at AA and it is the bar this rule measures. The 44 by 44 figure is SC 2.5.5 at AAA, a house bar the design kit aims at rather than a legal floor. EN 301 549 v3.2.1 still references WCAG 2.1, so 24 px is ahead of what the law harmonises today.

Without it, ad-hoc floors on the primary buttons leave the small ones untested, and a thumb on a 390px screen hits the neighbour.

Accepted when. At a 390px viewport, every button, icon control and menu item is 24 by 24 CSS pixels, or a 24 px circle centred on it clears its neighbours' circles - the spec's test, 24 px centre to centre, not 24 px of space. Links in a sentence are exempt.

Source. Web Content Accessibility Guidelines (WCAG) 2.2, W3C (standard) - 2.5.8 (AA), in part - https://www.w3.org/TR/WCAG22/#target-size-minimum

Pagination controls are links, not buttons

colibrity.pagination-crawlable - blocker - developer - proved by the build - a real a href, middle-clickable

Only when the listing has more items than fit on one page.

Render each page control as an anchor with a real href to that page's URL. A button type="button" is invisible to crawlers and to anyone who opens links in a new tab. Keep the existing accessible markup around it - nav with an accessible name, an ordered list, aria-current on the active page.

Without it, everything past page one falls out of the link graph, so those detail pages rank only if something else happens to link them.

Accepted when. Every page control in the pagination nav is an a element with an href that resolves to a 200. Middle-clicking page two opens it in a new tab.

Ships in wp-starter/archive-state.

Source. Technical SEO control grid, A third-party SEO agency, commissioned by Colibrity (client) - row 53 - not public

A block is three files sharing one slug

colibrity.block-file-triple - required - developer - proved by the build - template, stylesheet, script, one slug

Every block ships as template, stylesheet and script under the same slug, and the ACF layout name, the data-block attribute, the SCSS class and the script filename all derive from it. Use the scaffold command rather than creating the files by hand.

Without it, dead stylesheets and scripts pile up for blocks that no longer have a template, and every one of them still builds and ships.

Accepted when. For each block, the four names agree. No block has a stylesheet or script with no template, or a data-block value that matches no SCSS class.

Source. CONVENTIONS.md, wp-starter, our own repository (house) - CONVENTIONS.md:144-149, in part - not public

JavaScript targets data attributes, never CSS classes

colibrity.js-hooks-are-data-attributes - required - developer - proved by the build - data- for behaviour, classes for styling

Select elements for behaviour and animation through data- attributes. CSS classes are for styling only. This is what lets a component be restyled without breaking its animation.

Without it, a restyle renames a BEM class and an animation silently stops working, on the client's site, weeks after anyone reviewed the code.

Accepted when. No querySelector or querySelectorAll in block or module scripts selects by a styling class. Renaming a BEM class in the stylesheet breaks nothing at runtime.

Source. This standard's own decision record, Colibrity standard, published at /decisions/ (house) - docs/decisions.md:33-35 - https://standard.tastysites.pl/decisions/

Doctype and a correct lang attribute on every page

html.doctype-and-lang - blocker - developer - proved by the build - lang matches the visible copy, per page

Start every document with the HTML5 doctype and set lang on the html element to the page's actual language. On a multilingual site the value changes per page rather than being hard-coded in the header partial.

Without it, the English page inherits fr-FR from the header partial, and a screen reader pronounces the whole thing in French.

Accepted when. Every sampled page has the doctype and a lang attribute whose value matches the language of the visible copy.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 3.1.1 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/language-of-page.html

One h1 per page, and no skipped heading levels

html.single-h1 - required - developer - proved by the build

Give each page exactly one h1 naming the page, and no two pages the same one. Descend one level at a time. If a heading looks wrong at its correct level, that is a styling problem, not a structure problem.

Without it, a screen-reader user skimming by heading level hits an h3 under an h1 and cannot tell which section it belongs to.

Accepted when. Each sampled page has exactly one h1, no two sampled pages share an h1, and the heading outline never jumps a level going down, for example h2 straight to h4.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.3.1 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html

Page regions use landmark elements

html.landmarks - required - developer - proved by the build - exactly one main, every nav named

Use header, nav, main, aside and footer for what they are. One main per page and it wraps the page content. Give multiple navs distinct accessible names. Drop redundant role attributes that repeat the element's own semantics.

Without it, jumping by region skips the mobile menu and the site header, because neither one sits inside a landmark.

Accepted when. Each sampled page exposes exactly one main landmark, all content sits inside a landmark, and no two navigation landmarks share a name.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.3.1 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html

Lists are lists and tables are tables

html.lists-and-tables - required - developer - proved by the build - no layout tables, th with scope

Mark repeated items as ul or ol. Use table only for tabular data, with th and scope, never for layout. Card grids and pagination are lists.

Without it, a screen reader never announces the grid's item count, so nobody using one can tell how many results the filter returned.

Accepted when. No layout is built from table elements, every table has header cells with scope, and repeated content is inside a list element.

Source. HTML Living Standard, WHATWG (standard) - 4.9.1 The table element, in part - https://html.spec.whatwg.org/multipage/tables.html#the-table-element

A link goes somewhere, a button does something

html.buttons-vs-links - blocker - developer - proved by the build - never a div with a click handler

Anything that navigates is an anchor with a real href. Anything that acts on the current page is a button. Never a div with a click handler. This one decision drives keyboard support, right-click, new tab and crawlability at once.

Without it, a project card that navigates from a JavaScript click has no URL to copy, open in a new tab or share, since there is no href to take one from.

Accepted when. No clickable element is a div or span. Every navigating control has an href that resolves. Every control is reachable and operable with Tab and Enter.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 4.1.2 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html

Ids are unique on the rendered page

html.no-duplicate-ids - required - developer - proved by the build

Watch for ids baked into a partial that repeats. A repeated block with a hard-coded id silently breaks label association, aria references and anchor links.

Without it, clicking a label jumps focus to the first copy of a repeated block, and the visitor fills in the wrong form.

Accepted when. The validator reports no duplicate id on any sampled page, including pages that render the same block more than once.

Source. HTML Living Standard, WHATWG (standard) - 3.2.6 Global attributes, in part - https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute

Class names follow BEM and match their block slug

html.bem-naming - recommended - developer - proved by the build - block__element--modifier, block name = slug

Name classes block__element--modifier, where the block name is the block's own slug. A class that names its appearance rather than its role gets stale the first time the design changes.

Without it, the next developer greps a block's slug, gets no hits in the SCSS, and ends up editing the wrong stylesheet.

Accepted when. Every block's root class equals its slug, and no class name outside the design system describes a colour or a size.

Source. CONVENTIONS.md, wp-starter, our own repository (house) - CONVENTIONS.md:144-149, in part - not public

No inline event handlers or javascript: URLs

html.no-inline-handlers - required - developer - proved by the build

Bind behaviour in scripts, not in onclick attributes. Inline handlers block any meaningful Content Security Policy and scatter behaviour across templates.

Without it, any Content-Security-Policy worth having has to ship with unsafe-inline, the one setting that lets the injected script run.

Accepted when. No rendered page contains an on* event attribute or an href starting with javascript:.

Source. Delivered reference builds, audited together, Our own delivered work (house) - no on* attribute in either theme, both builds - not public

All dynamic output is escaped at the point of output

practices.output-escaping - blocker - developer - proved by the build - escape late, at output, per context

Escape every value as it is printed, using the function that matches its context - HTML text, attribute, URL, or a filtered allow-list for rich text. Escape late, at output, not early on input.

Without it, an editor pastes a script tag into a title field and it runs for every visitor on that page.

Accepted when. No template prints an unescaped dynamic value. A field containing a script tag renders as visible text rather than executing.

Source. CONVENTIONS.md, wp-starter, our own repository (house) - CONVENTIONS.md:37-41, in part - not public

Every page below the home page shows one breadcrumb trail

crawl.breadcrumb-trail - required - developer - proved by the build - one trail, root in page language, BreadcrumbList mirrors it

Render one breadcrumb trail on every page below the home page, in the served HTML, reflecting where the page sits rather than how the visitor got there. The root item is in the page's language, the last item is the current page and is not a link. Emit a BreadcrumbList carrying the same items, names and order as the visible trail. Two trails, or a trail its JSON-LD contradicts, is worse than none.

Without it, the breadcrumb Google prints in the result is not the one on the page - wrong language, a level short, on every result for the site.

Accepted when. Each sampled page below the home page shows exactly one trail, its root item in the page's language, its last item not a link, and its BreadcrumbList lists the same items in the same order under the same names. The Rich Results Test reports 0 BreadcrumbList errors.

Source. Google Search Central documentation, Google (vendor) - structured-data/breadcrumb#list-item, in part - https://developers.google.com/search/docs/appearance/structured-data/breadcrumb#list-item

The content that must rank is in the server HTML

crawl.content-in-server-html - required - developer - proved by the build - h1, copy and links present with JavaScript off

Put the h1, the body copy, the internal links and anything you want quoted in a result into the HTML the server returns. Client-side rendering is crawled on a second pass that is neither guaranteed nor prompt. Check it with curl or with JavaScript disabled, never in the Elements panel, which shows the rendered DOM and always looks fine.

Without it, the copy that has to rank waits in a render queue that may never come round, and DevTools shows it present the whole time.

Accepted when. curl on one URL of each template returns HTML containing the h1 text, the first paragraph of body copy and every link in the main navigation. Nothing that has to rank appears only once JavaScript has run.

Source. Google Search Central documentation, Google (vendor) - javascript-seo-basics#how-googlebot-processes-javascript, in part - https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics#how-googlebot-processes-javascript

The current page is marked in the navigation

states.current-page - required - developer - proved by the build - exactly one aria-current=page per navigation

Mark the item for the current page with aria-current="page" in every navigation - header, footer, breadcrumb. A section landing page marks its own item, not its whole branch. Give it a visible cue as well, and not colour alone: design.not-colour-alone grades that half, so this rule is graded on the attribute only.

Without it, a screen-reader user tabbing through the menu gets nothing saying which item is the page they are already standing on.

Accepted when. On five pages including one section landing page, the main navigation carries exactly one aria-current="page" and it is the item for the URL loaded. No other navigation on the page carries more than one.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 3, item 7, in part - not public

Interaction and states

Everything works with a keyboard alone

a11y.keyboard-operable - blocker - developer - proved by the build - Tab, Shift-Tab, Enter, Space, Escape

Operate the whole site with Tab, Shift-Tab, Enter, Space and Escape. Menus, modals, carousels, filters, video controls and custom selects all included. Nothing may need a mouse or a hover.

Without it, a mobile menu built from an href-less anchor never opens for someone on a keyboard - one delivered build shipped exactly that.

Accepted when. Every interactive control can be reached and operated by keyboard, focus never enters a hidden element, and no component traps focus except a modal, which releases it on Escape.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 2.1.1; 2.1.2 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html

Errors say what is wrong and how to fix it

a11y.form-errors - required - developer - proved by the build - in text, announced, focus on the first

Describe the problem in words next to the field, announce it to assistive technology, and move focus to the first error. Colour alone is not an error message.

Without it, a red border alone says nothing to the one man in twelve who cannot see it, and the failed field sits off-screen unfound.

Accepted when. Submitting an invalid form produces a text message identifying the field and the fix, the message is announced, and focus lands on the first invalid control.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 3.3.1; 3.3.3; 1.4.1; 4.1.3 (A, AA), in part - https://www.w3.org/WAI/WCAG21/Understanding/error-identification.html

prefers-reduced-motion is honoured everywhere

a11y.reduced-motion - blocker - developer - proved by the build - final state, not a faster animation

Check the media query in every animated module and in CSS, and honour it. Reduced motion means the content arrives in its final state, not that the animation runs faster. Parallax, auto-playing carousels and scroll-driven reveals cause the harm. Stopping auto-play is SC 2.2.2 at Level A and binds; honouring the preference is SC 2.3.3 at AAA, which is ours.

Without it, footer parallax keeps running for a visitor with a vestibular disorder who already asked their OS to stop it, and nausea lasts hours.

Accepted when. With reduced motion enabled at OS level, no element animates on scroll or entrance, all content is visible and in its final position, and nothing auto-plays.

Ships in wp-starter/motion-guard.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 2.2.2; 2.3.3 (A, AAA), in part - https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html

Modals trap focus, return it, and close on Escape

a11y.dialog-behaviour - required - developer - proved by the build

Only when the project has a modal, drawer, cookie banner, lightbox or off-canvas menu.

Move focus into the dialog on open, keep it inside while open, close on Escape, and return focus to the control that opened it. Mark the dialog and hide the rest of the page from assistive technology.

Without it, tabbing out of an open drawer lands on links behind it, and closing sends focus back to the top of the document, not the trigger.

Accepted when. Opening a modal moves focus inside, Tab cycles only within it, Escape closes it, and focus returns to the trigger.

Ships in wp-starter/dialog-a11y.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 2.1.2; 2.4.3; 4.1.2 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/no-keyboard-trap.html

Filters write their state to the URL

colibrity.filter-url-state - blocker - developer - proved by the build - state in the URL, cleared parameters deleted

Only when the page has a listing, archive or search with filters.

Keep one named list of filter parameters. On any filter change write the active values into the query string and delete the parameter when a filter is cleared, without adding a history entry. On load, the filter UI and the result set must both match the query string before results first paint. Client-rendered and server-rendered listings get there differently; the explainer has both.

Without it, a visitor who opens a result and presses Back lands on the unfiltered list, and nobody can send anyone the filtered view.

Accepted when. Changing a filter changes location.search. Reloading that URL restores the same result set and the same visible filter UI. Clearing a filter removes its parameter rather than leaving it empty. Copying the URL to another browser reproduces the view.

Ships in wp-starter/filter-url-state.

Source. Front-end brief, Colibrity (Michael Vasyuk, CTO) (client) - "each filter should update the URL parameters when changed", in part - not public

Filter changes do not litter the back button

colibrity.filter-history-noise - required - developer - proved by the build - one back press leaves the page

Only when the page has a listing, archive or search with filters.

Do not add a history entry per filter change. Ten filter clicks must not cost the visitor ten presses of the back button to leave the page. In a client-rendered listing that means history.replaceState rather than pushState. In a server-rendered one replaceState cannot help, because the navigation that follows pushes its own entry regardless - use location.replace(), which navigates without one.

Without it, ten filter clicks cost ten presses of Back to leave the page, which every visitor reads as a broken back button.

Accepted when. After changing filters five times, one press of the browser back button leaves the listing page entirely rather than stepping back through filter states.

Ships in wp-starter/filter-url-state.

Source. Delivered reference builds, audited together, Our own delivered work (house) - replaceState on filter change, one build of the two - not public

Every page of a listing has its page number in the URL

colibrity.pagination-in-url - blocker - developer - proved by the build - /page/2/, and it works with JS off

Only when the listing has more items than fit on one page.

Render real server-side pagination at /page/2/ and up. Load-more, if present, is an enhancement layered over it and must advance the URL as it loads each batch. A listing whose only navigation is an offset load-more button does not satisfy this rule.

Without it, a crawler loads the listing, sees twelve of two hundred items and never presses the button, so the other 188 go undiscovered.

Accepted when. Page two is reachable at a distinct URL containing the page number, that URL loads the correct items with JavaScript disabled, and its page number is visible in the address bar after using load-more.

Ships in wp-starter/archive-state.

Source. Front-end brief, Colibrity (Michael Vasyuk, CTO) (client) - "each pagination page should have the page number reflected in the URL", in part - not public

Animation is cheap and stops when off screen

perf.animation-cost - required - developer - proved by the build - transform and opacity only

Animate transform and opacity only. Pause any infinitely repeating tween while its section is out of view. Use svh rather than lvh on pinned sections. Do not add per-block resize handlers.

Without it, an accordion animating height relays out the page on every frame, making the one interaction people actually use the jankiest thing there.

Accepted when. No looping animation runs while off screen, a performance trace shows no layout thrash during scroll, and no animation drives a layout-triggering property.

Source. web.dev developer guides, Google (web.dev) (vendor) - articles/animations-guide, in part - https://web.dev/articles/animations-guide

Scripts clean up after themselves on page transition

practices.self-cleaning-modules - required - developer - proved by the build - listeners, tweens and observers torn down

Only when the project uses client-side page transitions.

Every module returns or registers a teardown that removes its listeners, kills its animations and disconnects its observers. Without it, navigating back and forth stacks duplicate handlers until the page stutters.

Without it, the fifth page a visitor opens runs five copies of the same scroll handler, and only long sessions ever show it.

Accepted when. Navigating between two pages five times and returning leaves the same number of listeners and scroll triggers as the first load.

Source. Delivered reference builds, audited together, Our own delivered work (house) - src/js/core/page-lifecycle.js destroyPage(), both builds - not public

A soft navigation leaves the document as a hard load would

practices.soft-navigation-parity - required - developer - proved by the build - head, body classes, styles and page view follow the page

Only when the project uses client-side page transitions.

Client-side transitions swap the container and leave everything outside it as the first page rendered it. After every soft navigation, re-derive what describes the page: the title, the canonical and social tags, the body classes, the per-page stylesheets and the analytics page view. Read them off the incoming document rather than patching by hand, in the transition chunk, not the main entry.

Without it, every share, analytics hit and screen-reader title from the second page onward describes the page the visitor arrived on.

Accepted when. Navigate two pages by transition, then compare the live document with a hard load of the same URL: title, canonical, og:url, og:type, body classes and loaded stylesheets match, and exactly one page view fired per navigation.

Source. Delivered reference builds, audited together, Our own delivered work (house) - head-sync.js in the third build and the starter since 2026-08-29; neither older build re-derives the head - not public

Content is visible when JavaScript does not run

states.no-js-fallback - blocker - developer - proved by the build - JS off: 0 elements left at opacity 0 or out of viewport

Never ship a reveal whose resting state is invisible in the stylesheet. Set the hidden state from script, or gate it on a class the script adds, so a page with no JavaScript renders everything in its final position. crawl.content-in-server-html, in the SEO volume, owns the other half of one JavaScript-off load: the copy is in the response bytes. This rule owns that it is then visible.

Without it, one failed script leaves dozens of sections at opacity 0, and the visitor gets a blank page while the copy sits there in the HTML.

Accepted when. With JavaScript disabled, load the home page and two inner templates. Zero elements remain at opacity 0, visibility hidden or translated out of the viewport, and every section's copy is readable.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 2, item 12 - not public

The preloader has a hard cap and a fallback

states.preloader-budget - blocker - developer - proved by the build - page visible within 3 s of first byte whatever fails

Only when the project shows a preloader before the first paint.

Give the preloader a timeout that reveals the page whatever has not finished, and never gate it on an event that can fail - a font, a video, a third-party script. Until it clears the visitor sees nothing at all, on exactly the connections it hurts most. wp-starter has no preloader: site-transition.php is a route-change wipe and nothing gates first paint, so the cap is project work.

Without it, one font or third-party script that never resolves leaves the visitor staring at an opaque overlay with no way past it.

Accepted when. Throttle to Slow 3G, then repeat with the preloader's own assets blocked. The page is readable within 3 seconds of the first byte in both runs, and neither leaves the overlay on screen.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 2, item 11, in part - not public

Every interactive element ships all of its states

states.interactive-set - required - developer - proved by the build - hover, active, disabled and open, script-inserted included

Style hover, active, disabled and open for every control - buttons, links, inputs, selects, accordion and menu triggers. Bind the styles to a class or a data attribute, never to the markup the server rendered, so a card arriving from a filter fetch gets them too. Anything a hover reveals is reachable by a tap as well, or it is decoration. Focus belongs to a11y.focus-visible.

Without it, a card arriving from a filter fetch is dead to the touch while an identical one above it lights up on hover.

Accepted when. Sample one control of each kind on three templates: hover, active, disabled and open each render a visible change. An element inserted by a filter or a load-more shows the same four states as one present at first load. Nothing a hover reveals is the only route to it on touch.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - per-page item 13, in part - not public

Anything that takes time says so

states.loading-feedback - required - developer - proved by the build - visible feedback within 500 ms, cleared on failure too

Any action that can outlast half a second - a submit, a filter fetch, a load-more, a route change - shows it is working within 500 ms, and clears that state on failure as well as on success. Make it aria-busy or a live-region message, not only a spinner graphic. wp-starter ships no generic skeleton or spinner, so this is per-feature work. This rule owns the 500 ms threshold.

Without it, people hit submit a second time because nothing moved, and a failed request leaves the spinner turning forever.

Accepted when. Throttle to Slow 3G, then submit a form and change a filter. Each shows a busy state within 500 ms. Force one to fail and the busy state clears rather than leaving a spinner running forever.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 4, item 14, in part - not public

Scroll reveals fire on deep links and on back navigation

states.scroll-reveal-recovery - required - developer - proved by the build - 0 elements hidden past their trigger, after #anchor or back

Only when content on the project animates in as the visitor scrolls.

Three arrivals start the visitor below the trigger: a /page#section deep link, the back button, and a bfcache restore. A reveal bound to a downward scroll fires on none of them. Play anything already past its trigger on init, and refresh trigger positions on pageshow rather than on load alone.

Without it, arriving on a /page#section link or pressing back lands the visitor in a stretch of page that never un-hides.

Accepted when. Load a /page#section link deep in the page, then leave and press back. Zero elements above the scroll position are still hidden, and the same holds after a bfcache restore rather than a fresh load.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 2, item 14 - not public

Sticky header and menu scroll behaviour is decided up front

states.sticky-chrome - required - developer - proved by the build - 0 shift on stick, anchor clears the header, scroll locked

Write down what the header does on scroll - stays, hides going down and returns going up, or none of it. Static is a valid answer; nobody deciding is not, and that is how one section hides the header and another does not. Reserve its height so nothing jumps when it sticks, offset anchor targets by it, and lock the body scroll while an off-canvas menu is open.

Without it, the page behind an open off-canvas menu scrolls away under the visitor's finger, and every anchor lands its heading under the header.

Accepted when. Scrolling past the stick point shifts the page by 0 pixels. A link to #section leaves its heading fully below the header. With the menu open the page behind it does not scroll, and closing it returns to the same position.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 11, item 6, in part - not public

Forms

File uploads declare what they accept and why they refuse

forms.file-uploads - blocker - developer - proved by the build - sniffed-MIME allow-list, a byte cap, a named reason

Only when the project has a form that collects data from a visitor, and a form on the project accepts a file upload.

wp-starter ships no upload handling at all, so this is new code on every project and gets reviewed as such. Check the file's sniffed type against an allow-list, cap the bytes, generate the stored filename yourself, and keep uploads out of the web root or behind a rule that will not execute them. An extension check is not a check - a PHP renamed to .pdf passes it. Name the limit they hit.

Without it, an extension check passes a file that is not what it claims, and it lands in a folder that will happily execute PHP.

Accepted when. A PHP file renamed to .pdf is refused. A file one byte over the documented cap is refused. Both refusals name the reason on screen. Requesting a stored file's URL returns a download or 403, never executed output.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 4, item 16, in part - not public

Every validation rule runs again on the server

forms.server-side-validation - blocker - developer - proved by the build - a POST that bypasses the form is rejected field by field

Only when the project has a form that collects data from a visitor.

Re-run every rule the browser runs, on the server, before anything is stored or emailed: required fields, declared types, lengths, consents. Everything the browser checks is optional from the attacker's side - the endpoint is public and takes a POST from anything. Use wp-starter's form registry, where one field map drives validation, storage, the notification and the export.

Without it, curl posts past every browser check and writes a message record, a consent record and a notification email out of whatever junk it sent.

Accepted when. A POST to the form endpoint carrying a missing required field and a malformed email is rejected naming both fields, and that request creates no message record, no consent record and no notification email.

Ships in wp-starter/form-registry.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 4, item 10 - not public

Forms carry three layers of spam defence

forms.anti-spam - required - developer - proved by the build - honeypot, a scored gate, and a per-IP rate limit

Only when the project has a form that collects data from a visitor.

Three layers, because each stops a different attacker. A honeypot field, hidden from people and filled in by naive bots. A scored gate such as reCAPTCHA v3 or Turnstile, configured to refuse when its verifier is unreachable - wp-starter's is off by default and fails open. And a per-IP rate limit, which wp-starter does not ship at all: build it, or one solved nonce buys unlimited submissions.

Without it, the captcha verifier times out once and everything sent that minute is waved through - both delivered themes return a pass on a transport error.

Accepted when. The hidden field filled in stores nothing. 20 POSTs from one IP inside a minute get a 429 before the last one, and the refused POSTs write no message and no consent record. With the scored gate's verifier unreachable, a submission is refused.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 4, item 11, in part - not public

A successful submit produces a confirmation nobody can miss

forms.confirmation - required - developer - proved by the build - focus lands on it, announced, still there at 30 s

Only when the project has a form that collects data from a visitor.

Decide here whether success is a thank-you URL or an inline confirmation, and write the decision into the handover - the noindexed thank-you page and the conversion event both depend on which it is. Then move focus to the confirmation, announce it to assistive technology, and let it stay. A toast that fades after four seconds fails: the one who most needs it looked away while it sent.

Without it, the confirmation is gone in three seconds, as it is in both delivered themes, and the visitor sends the form again to check it worked.

Accepted when. After a successful submit, focus is on the confirmation, a screen reader announces it, and it is still on screen 30 seconds later. Where it is a thank-you page, the handover names its URL.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 4, item 15, in part - not public

An error appears on blur, not while someone is typing

forms.error-timing - required - developer - proved by the build - shown when the field is left, cleared once valid

Only when the project has a form that collects data from a visitor.

Validate a field when the visitor leaves it, not while they are still typing. An error on the third character of an email address tells someone they are wrong before they have finished being right. Once a message is showing, clear it the moment the value becomes valid, without making them leave the field a second time. On submit, check everything again.

Without it, half a typed email address gets marked wrong in red mid-word, and the message sits there after the visitor has finished fixing it.

Accepted when. Typing three characters into an empty email field shows no error. Leaving the field shows one. Typing until the value is valid clears it without leaving the field again.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 4, item 5, in part - not public

Form inputs are 16 pixels so iOS does not zoom on focus

forms.input-font-size - required - developer - proved by the build - 16 px computed at 390 px, and pinch-zoom still works

Only when the project has a form that collects data from a visitor.

Give every input, select and textarea a computed font-size of at least 16px at mobile widths. Under that, iOS Safari zooms the page on focus and never zooms back out. Do not fix it by disabling pinch-zoom in the viewport meta: that trades one bug for a WCAG 2.1 resize-text failure and contradicts the 200 percent zoom rule.

Without it, tapping the email field on an iPhone zooms the page in and leaves the rest of the form off the right edge with no way back.

Accepted when. At a 390px viewport, every input, select and textarea computes to 16px or more, focusing one does not change the page scale, and the viewport meta sets neither user-scalable=no nor a maximum-scale.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 4, item 1, in part - not public

Every field declares its type and its autocomplete token

forms.input-type-and-autocomplete - required - developer - proved by the build - a token from the spec list on every identity field

Only when the project has a form that collects data from a visitor.

Set the input type that matches the data - email, tel, url, number - so the phone keyboard changes with the field. Put an autocomplete token from the HTML spec list on every field asking for the visitor's own details. SC 1.3.5 at AA asks only that such a field's PURPOSE be programmatically determinable; autocomplete is how we do it, and the word appears nowhere in WCAG 2.1.

Without it, a phone user retypes name, email, phone and postcode by hand - a delivered contact form ships six identity fields with no autofill token.

Accepted when. Every identity field on the sampled forms carries an autocomplete token from the HTML spec list and an input type matching its data, with zero invalid-token findings. Focusing the email field on a phone brings up the email keyboard.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.3.5 (AA), in part - https://www.w3.org/TR/WCAG21/#identify-input-purpose

A submit happens once and loses nothing

forms.submit-lifecycle - required - developer - proved by the build - 1 record on a double click, values kept on failure

Only when the project has a form that collects data from a visitor.

Make a repeated submission idempotent on the server, not merely unclickable in the browser. Derive a key from the payload, claim it once, and answer a replay with the same success - the theme already does this for conversion events in wps_capi_claim(). wp-starter ships nothing for forms: the client latch defeats a double click and nothing else, so two identical POSTs write two records.

Without it, an impatient double click writes two message records and two consent records, and the consent log is the thing a GDPR auditor counts.

Accepted when. The same payload submitted twice, once by double click and once by replaying the POST, leaves exactly 1 message record and 1 consent record. A failed submit returns the form with every value the visitor typed still in it.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 4, item 12, in part - not public

Email and phone are checked without rejecting valid values

forms.value-formats - required - developer - proved by the build - email needs a dot TLD, phone stored E.164 if international

Only when the project has a form that collects data from a visitor.

type=email is not enough on its own: the HTML Living Standard accepts a@b, because intranet mail is valid. Add a check that the domain has a dot and a TLD, and stop there. Anything stricter refuses real customers, and the only proof an address works is a confirmation email that arrives. Put no pattern on the phone field - numbering plans change. Strip spaces, store international numbers as E.164.

Without it, phone numbers reach the CRM as +33 6 12 34 56 78, spaces and all, so nothing downstream can dial, dedupe or match them.

Accepted when. test@example is refused, test@example.com is accepted, and no address in the reviewer's list of 10 valid addresses is refused. +33 6 12 34 56 78 is accepted and stored as +33612345678.

Source. HTML Living Standard, WHATWG (standard) - 4.10.5.1.5 Email state (type=email), in part - https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address

A form still submits from a cached page

forms.cache-safe-submit - required - developer - proved by the build - fresh token at submit time, from a no-store endpoint

Only when the project has a form that collects data from a visitor.

Assume every page reaches an anonymous visitor from a cache. A nonce printed into the page is printed into the cache, where it expires for everyone while it keeps working for the logged-in developer who tests it. Fetch the token at submit time from an endpoint that answers no-store, and never read the consent cookie server-side on a page that can be cached.

Without it, every anonymous submit fails with a 403 while the developer's logged-in test passes, and the enquiries are lost in silence.

Accepted when. A form on a page served with a cache HIT older than the token lifetime submits successfully as an anonymous visitor, and the token request answers with Cache-Control: no-store.

Ships in wp-starter/form-registry.

Source. Delivered reference builds, audited together, Our own delivered work (house) - a fresh-nonce route in the third build; the two others print the nonce into the page - not public

Stored form submissions are unreachable without logging in

security.submission-store-private - blocker - developer - proved by the build - submission URLs return 401 or 404 anonymously

Only when the project has a form that collects data from a visitor.

Register anything holding a submission - messages, subscribers, consent records - as a non-public post type: not public, not publicly queryable, excluded from search, with mapped meta capabilities. Nothing you import covers a submission type your project adds, so check every one you register. GDPR article 32 makes confidentiality a legal obligation, not good practice, which is why it blocks.

Without it, every message the contact form ever took is readable at a guessable URL, which is a personal-data breach the client has to report.

Accepted when. wp post type list --public=1 lists no submission type, and requesting a stored submission by permalink and through its REST route without logging in returns 401 or 404 in both cases.

Source. Regulation (EU) 2016/679 (General Data Protection Regulation), consolidated text, EUR-Lex, Publications Office of the European Union (law) - Art. 32(1)(b), in part - https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A02016R0679-20160504

Login and every public form endpoint are rate limited

security.public-post-rate-limit - required - developer - proved by the build - 20 rapid POSTs, refused before the 20th

Only when the project has a form that collects data from a visitor.

Count attempts per IP and per route and refuse past a threshold. A transient counter is enough - there is no CDN and no WAF in front of these sites, so if the application does not count, nothing does. Cover the login form and every public route that accepts a submission. A honeypot and a captcha stop bots; neither stops a script pointed at the login.

Without it, wp-login.php takes password guesses as fast as a script can send them, and only the host is counting, if it counts at all.

Accepted when. 20 POSTs to wp-login.php inside one minute from one IP are refused with 429 or an explicit rejection before the 20th, and one normal login still succeeds after the window. Public form endpoints are graded by forms.anti-spam, not here.

Source. OWASP Application Security Verification Standard 5.0.0, OWASP Foundation (standard) - 6.3.1 (L1), in part - https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x15-V6-Authentication.md#v63-general-authentication-security

Consent and legal

Every consent is recorded with what it takes to prove it

legal.consent-record - blocker - developer - proved by the build - who, when, the exact wording shown, and its version

Only when the project has a form that collects data from a visitor.

Write one record per consent carrying who gave it, the timestamp, the exact wording they were shown and the version of that wording. Resolve the wording from the same function the label renders from, so shown and stored cannot drift. Records stay deletable by an administrator, so where a dispute is plausible, back the log up rather than trusting the post table.

Without it, a disputed opt-in comes down to your word against theirs, and nothing stored says what wording the person actually saw.

Accepted when. Submit a form with the box ticked and open the stored record: it identifies the person, carries a timestamp, and quotes the wording verbatim with a version. Change the published wording, submit again, and the two records differ in wording and in version.

Ships in wp-starter/consent-log.

Source. Regulation (EU) 2016/679 (General Data Protection Regulation), consolidated text, EUR-Lex, Publications Office of the European Union (law) - Art. 7(1), in part - https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A02016R0679-20160504#art_7

Refusing cookies is as easy as accepting them

legal.cookie-banner - blocker - designer - proved by the build - refuse in one click, at the same visual weight as accept

Only when the project loads analytics, advertising or marketing tags.

Give refuse and accept the same prominence on the banner's first screen - same size, same contrast, same level in the hierarchy. A refuse buried one panel deeper is the asymmetry CNIL sanctioned on 31 December 2021: EUR 150 million against Google, EUR 60 million against Meta. CNIL also recommends a choice be honoured for six months; wp-starter's cookie runs twelve, which is the developer's half.

Without it, refusing takes more clicks than accepting, so CNIL does not count what the banner collected as consent at all.

Accepted when. Both choices are reachable in one click from the banner's first screen, at the same size, weight and contrast. Refusing takes no more clicks than accepting, and neither is styled as the quieter of the two.

Source. Recommandation cookies et autres traceurs, version consolidee, CNIL (Commission nationale de l'informatique et des libertes) (regulator) - 2.3 (modalites du refus), in part - https://www.cnil.fr/sites/default/files/2026-01/recommandation_cookies_consolidee.pdf#page=6

The privacy policy is a real page, linked where data is taken

legal.documents-published - blocker - developer - proved by the client - own permalink, 200, linked from footer and every form

Publish the privacy policy at its own permalink and link it from the footer and from every form that collects personal data. The text is the client's to supply; the developer publishes it. Keep it out of any blanket noindex sweep - it is the first page a regulator looks for. A French site also owes a separate mentions legales page. That is LCEN, not GDPR, and this rule does not score it.

Without it, a form collects names and emails while its policy link 404s or sits behind a noindex sweep.

Accepted when. The policy URL returns 200 on its own permalink, is linked from the footer and from every form that collects personal data, and carries no noindex. The client has confirmed in writing that the published text is the version their counsel approved.

Source. Regulation (EU) 2016/679 (General Data Protection Regulation), consolidated text, EUR-Lex, Publications Office of the European Union (law) - Art. 13(1), in part - https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A02016R0679-20160504#art_13

Newsletter consent is its own optional checkbox

legal.newsletter-separate - blocker - developer - proved by the build - terms ticked, newsletter unticked: 0 subscriber records

Only when the project has a form that collects data from a visitor, and the project captures newsletter or marketing consent.

Give the newsletter its own box: unticked, optional, with its own wording naming the sender and the frequency. One box must never cover both the terms and the mailing list - consent has to be specific to each separate purpose, and a bundled box cannot show which one the person agreed to. wp-starter's subscriber store ships commented out, so this is a module you switch on.

Without it, somebody downloading a PDF lands on the mailing list from a box that never mentioned one, and no record says which they agreed to.

Accepted when. Submit with the terms ticked and the newsletter box untouched: zero subscriber records are created and no address reaches a mailing list. The newsletter box is unticked on a fresh load and the form submits successfully without it.

Source. Regulation (EU) 2016/679 (General Data Protection Regulation), consolidated text, EUR-Lex, Publications Office of the European Union (law) - Art. 4(11), in part - https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A02016R0679-20160504#art_4

No analytics or marketing tag fires before a choice

legal.no-tags-before-consent - blocker - developer - proved by the build - 0 third-party requests before a choice, exactly 1 container

Only when the project loads analytics, advertising or marketing tags.

Load nothing from a tag vendor until the visitor has chosen. Grade this on network requests, not configuration: Consent Mode defaulted to denied still loads gtag and still pings Google. Ship exactly one tag container. Withhold any vendor with no consent-mode equivalent entirely, and record in the handover that container access lets the client undo this.

Without it, Google has already been pinged before the visitor is asked, so refusing changes nothing and the banner is decoration.

Accepted when. On a first visit with no choice made, the network log shows 0 requests to any analytics or marketing host. Accepting the analytics category makes those requests appear; refusing keeps the count at 0.

Ships in wp-starter/analytics-registry.

Source. Recommandation cookies et autres traceurs, version consolidee, CNIL (Commission nationale de l'informatique et des libertes) (regulator) - 2 (chapeau), in part - https://www.cnil.fr/sites/default/files/2026-01/recommandation_cookies_consolidee.pdf#page=2

The terms box starts unchecked and the server enforces it

legal.terms-consent-checkbox - blocker - developer - proved by the build - unchecked after a back-nav, refused server-side, names it

Only when the project has a form that collects data from a visitor.

Ship the box unchecked, refuse the submit server-side when it is missing, and name the document and its version in the label beside a working link. Set autocomplete=off on that box: Firefox and Safari restore form state on back-navigation and would re-tick it. That is the opposite of the advice on identity fields, and deliberate. This proves acceptance of a contract, so do not label it consent.

Without it, Safari and Firefox re-tick the box on a back-navigation, so the record shows an acceptance the visitor never made.

Accepted when. Submit with the box unticked: the server returns an error and stores nothing. Tick it, submit, then press back - it is unticked again. The stored record names the document and the version that was shown.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 5, item 3, in part - not public

The consent log is exportable and has a retention period

legal.consent-retention - required - ops - proved by the client - a stated period, an export, and a job that runs

Only when the project has a form that collects data from a visitor.

Ask the client or their DPO for the retention period and publish it in the privacy policy: Art. 13(2)(a) requires the period, or the criteria for setting it, to be told to the person. Then build the job that deletes past it, which is what discharges Art. 5(1)(e). A stated period nobody enforces satisfies neither. wp-starter ships no purge job, so this one is yours to write.

Without it, records pile up behind a policy that promises they are deleted, which is worse than promising nothing.

Accepted when. The period is stated in writing by the client or their DPO and appears in the privacy policy. An export of the log produces a file carrying every stored key. A record aged past the period is gone after the scheduled job has run once.

Source. Regulation (EU) 2016/679 (General Data Protection Regulation), consolidated text, EUR-Lex, Publications Office of the European Union (law) - Art. 13(2)(a); Art. 5(1)(e), in part - https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A02016R0679-20160504

No personal data travels in a URL

legal.no-pii-in-urls - required - developer - proved by the build - 0 emails, names, phones or tokens in a query string or path

Keep personal data out of every URL you generate, redirect to or link. A URL leaks four ways at once: the access log, the Referer header sent to every third party the next page loads, the analytics page path, and browser history on a shared machine. The usual failure is a thank-you redirect carrying the address so the page can greet the visitor - use a server-side flash instead.

Without it, an address typed into one form turns up months later in the analytics page-path report and in a shared browser's history.

Accepted when. Submit every form and follow each redirect: no resulting URL contains an email, a name, a phone number or an identifying token. A search of the analytics page-path report returns zero such URLs.

Source. Regulation (EU) 2016/679 (General Data Protection Regulation), consolidated text, EUR-Lex, Publications Office of the European Union (law) - Art. 5(1)(f), in part - https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A02016R0679-20160504#art_5

Consent records carry the context of the consent

legal.consent-record-context - recommended - developer - proved by the build - source URL, purpose and method, each set by the caller

Only when the project has a form that collects data from a visitor.

Alongside the four fields that prove a consent, store where it was given, what it was for, and how it was collected. Set the method at the call site: wp-starter hardcodes it to checkbox, which is honest for a site with one path and becomes a lie the day a double opt-in is added. Where one form captures two consents, the purpose is what tells the records apart.

Without it, every record still claims a ticked checkbox the day a double opt-in ships, and two consents from one form cannot be told apart.

Accepted when. Two consents captured by one submit produce two records with different purposes. Each record names the URL it was given on and a collection method matching how it was actually collected, not a default string.

Source. Regulation (EU) 2016/679 (General Data Protection Regulation), consolidated text, EUR-Lex, Publications Office of the European Union (law) - Art. 7(1), in part - https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:02016R0679-20160504

Media and payload

Images carry alt text that earns its place

a11y.alt-text - blocker - developer - proved by the build - empty alt on decorative, never "image of"

Describe what the image conveys in context. Decorative images take an empty alt so they are skipped. Do not write "image of". An image inside a link describes the destination.

Without it, a screen reader reads out the filename instead, and a client-filled media library passes the scanner with alt text nobody wrote.

Accepted when. Every image has an alt attribute, decorative ones are empty, and reading the alt text of a page aloud conveys the same information as seeing it.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.1.1 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html

Video and audio carry captions and controls

a11y.media-alternatives - required - developer - proved by the build - captions on speech; audio over 3s, motion over 5s

Only when the project has video or audio, or content that starts moving on its own.

Caption anything with speech. Audio that plays automatically for more than three seconds needs a pause, stop or volume control - SC 1.4.2, Level A, and this rule said five seconds for everything until 2026-08-26. Moving content that starts on its own and runs past five seconds needs the same, under SC 2.2.2. Captions must be accurate, not machine output nobody read.

Without it, a deaf visitor gets nothing from a video testimonial, and the one delivered build with speech video shipped zero track elements.

Accepted when. Every video with speech has captions that match the audio, and every auto-playing item can be paused with a keyboard.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.4.2; 1.2.2; 2.2.2 (A), in part - https://www.w3.org/WAI/WCAG21/Understanding/audio-control.html

Images ship in a modern format at the size they display

perf.image-formats - required - developer - proved by the build - WebP or AVIF with srcset and sizes, 200 KB per image at most

Serve WebP or AVIF with a fallback, generate responsive sizes, and use srcset and sizes so a phone never downloads a desktop image. Cap every image at 200 KB on the wire, and ship an animated GIF as a muted looping video, never as a GIF. A full-width PNG hero is the single most common cause of a slow mobile LCP on these projects.

Without it, a phone on mobile data downloads the 2000px desktop hero to paint it 390px wide, several times the bytes it needed.

Accepted when. No image transfers more than twice the bytes needed at its displayed size, the image audits report no meaningful savings from format or sizing, no single image exceeds 200 KB on the wire, and no animated GIF is served.

Source. Web Vitals, Google (web.dev) (vendor) - choose-the-right-image-format, in part - https://web.dev/articles/choose-the-right-image-format#features_of_different_raster_image_formats

Below-the-fold images lazy load, the LCP image does not

perf.lazy-and-priority - required - developer - proved by the build - lazy below the fold, fetchpriority on LCP

Put loading lazy on images below the fold and never on the LCP image, which instead takes fetchpriority high. Lazy-loading the hero delays the very metric it is meant to help.

Without it, a bulk find-and-replace puts loading=lazy on the hero and delays the one request the entire metric hangs on.

Accepted when. Every below-the-fold image has loading lazy, the LCP image has neither lazy nor async decoding and carries fetchpriority high.

Source. Web Vitals, Google (web.dev) (vendor) - optimize-lcp, in part - https://web.dev/articles/optimize-lcp#optimize_the_priority_the_resource_is_given

Fonts are subset woff2, self-hosted, with a swap policy

perf.fonts - required - developer - proved by the build - woff2 only, none over 100 KB, one preload

Ship woff2 only, subset to the characters the site actually uses, self-hosted, with font-display swap and a preload for the one font used above the fold. An unsubset variable font can be several hundred kilobytes on its own.

Without it, half a megabyte of typefaces ships unnoticed, since nothing in the performance score is weighted against font bytes.

Accepted when. No font file over 100 KB, no ttf or otf served to browsers, font-display is set, and at most one font is preloaded.

Source. PERFORMANCE.md, wp-starter, our own repository (house) - PERFORMANCE.md:135-144, in part - not public

Video is deferred and never blocks the first paint

perf.video-weight - required - developer - proved by the build - poster, preload none, muted, playsinline, 2 MB per file

Only when the project has background or hero video.

Give the video a poster, set preload none until it is near the viewport, keep autoplay muted and playsinline, and start playback after load so the poster carries LCP. Keep each file under 2 MB; anything longer streams from a service or is cut. Above-the-fold autoplay video is usually the reason mobile LCP cannot be fixed by anything else.

Without it, someone on mobile data pays for two megabytes of decorative clip before the page is usable, and never presses play.

Accepted when. The video's own bytes are not on the critical path, LCP resolves to the poster or another element, total transfer on first load stays inside the page budget, and no video file served from the site exceeds 2 MB.

Source. PERFORMANCE.md, wp-starter, our own repository (house) - PERFORMANCE.md:465-520, in part - not public

Uploads cannot execute, and no script-capable type is allowed

security.upload-types-and-execution - blocker - ops - proved by the build - no script-capable type, uploads do not execute

Add no mime type that can carry script - svg, html, js - unless a named sanitiser handles it and a capability gates who may upload it. Then stop the uploads directory executing PHP at the server level. That deny rule cannot live in the theme directory, it goes out with the mu-plugin sync or the stage script, so it is easy to write once and never deploy.

Without it, an SVG with a script in it runs on the site's own origin, so one contributor upload becomes a shell or a stolen admin session.

Accepted when. The upload_mimes filter adds no script-capable type, or names both the sanitiser and the capability gating it. The deny-execution file for wp-content/uploads is shown to the review, read off the server rather than out of the repository.

Source. OWASP Application Security Verification Standard 5.0.0, OWASP Foundation (standard) - 5.3.1 (L1), in part - https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x14-V5-File-Handling.md#v53-file-storage

SEO head and schema

Paged views are canonical to themselves

colibrity.paged-canonical - required - developer - proved by the build - page two canonicals to page two

Only when the listing has more items than fit on one page.

Point the canonical of /page/2/ at /page/2/, never back at page one, and put the page number in the title. Canonicalising every page to the first one tells the crawler the other pages do not exist.

Without it, page two tells the crawler it does not exist, so it stops being followed and the items only listed there quietly leave the index.

Accepted when. The canonical on page two equals page two's own URL, and page two's title differs from page one's.

Source. Google Search Central documentation, Google (vendor) - pagination-and-incremental-page-loading#use-urls-correctly, in part - https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading#use-urls-correctly

Filter combinations do not become a crawl trap

colibrity.filter-indexability - required - developer - proved by the build - indexable and in the sitemap, or canonicalised

Only when the page has a listing, archive or search with filters.

Decide per filter whether its URLs should be indexed. Filters that match real search demand stay indexable and appear in the sitemap. Sort orders, free-text search and multi-value combinations canonicalise to the unfiltered listing or carry noindex. Write the decision down in the project handover.

Without it, multi-select filters mint combinations faster than any crawler can exhaust them, and the budget meant for product pages goes there instead.

Accepted when. A filtered URL either canonicalises to the base listing or is deliberately indexable and present in the sitemap. No filter combination generates an unbounded set of indexable URLs.

Source. Google Search Central documentation, Google (vendor) - crawling-managing-faceted-navigation, in part - https://developers.google.com/search/docs/crawling-indexing/crawling-managing-faceted-navigation

Every indexable page declares a self-referencing canonical

seo.canonical - blocker - developer - proved by the build - one, absolute, on the live host

Emit exactly one canonical link per page pointing at that page's own absolute URL, on the production domain, with the scheme and host the site actually serves.

Without it, one rename leaves 87 URLs canonicalising somewhere else, and the crawler consolidates the whole set onto the wrong address.

Accepted when. Each sampled page returns one canonical, absolute, on the live host, equal to the URL that was requested once redirects have resolved.

Source. Google Search Central documentation, Google (vendor) - crawling-indexing/consolidate-duplicate-urls, in part - https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls

robots.txt exists and points at the sitemap

seo.robots-txt - required - developer - proved by the build - an absolute Sitemap: line, itself a 200, no asset Disallowed

Ship a robots.txt that disallows sandbox, styleguide and any internal preview route, and names the sitemap with an absolute URL. Never disallow the theme's assets or the uploads: a crawler that cannot fetch the CSS renders a blank page and grades it.

Without it, a relative Sitemap line is never resolved, and the preview routes sit crawlable - /sandbox/ answered 200 on a live site.

Accepted when. /robots.txt returns 200, contains a Sitemap: line with an absolute URL that itself returns 200, does not disallow any route that should rank, and disallows nothing a page needs to render: scripts, styles, images or fonts.

Source. Google Search Central documentation, Google (vendor) - crawling/docs/robots-txt/create-robots-txt, in part - https://developers.google.com/crawling/docs/robots-txt/create-robots-txt

The sitemap lists every indexable URL and nothing else

seo.sitemap - required - developer - proved by the build - one generated sitemap, all 200, none noindex

Generate the sitemap rather than maintaining it by hand, from one source: when an SEO plugin emits it, switch the core one off, so two indexes never disagree. Exclude anything that is noindex, redirected or gone. Hand-maintained sitemaps go stale the first week nobody remembers them.

Without it, seven URLs in one live sitemap answer 404, six of them category archives with no template, and the crawler is handed them on purpose.

Accepted when. The sitemap index returns 200, every child returns 200, a sample of ten listed URLs returns 200 with no redirect, and no listed URL carries noindex. Only one sitemap source answers; the other returns 404, and robots.txt names the one that does.

Source. Google Search Central documentation, Google (vendor) - crawling-indexing/sitemaps/build-sitemap, in part - https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap

Open Graph and Twitter card tags are complete

seo.open-graph - required - developer - proved by the build - 8 tags, absolute og:image at 1200x630

Emit og:title, og:description, og:type, og:site_name, og:locale, og:url and an absolute og:image, plus twitter:card. A relative og:image renders as no image at all when shared.

Without it, the client shares the launch post on LinkedIn and gets a grey box with a bare URL where the brand image should be.

Accepted when. All eight tags are present on a sampled page, og:url matches the canonical, and the og:image URL is absolute and returns an image of at least 1200 by 630.

Source. The Open Graph protocol, ogp.me, originally created at Facebook (standard) - og:title, og:type, og:image, og:url, in part - https://ogp.me/#metadata

Structured data is present, valid and singular

seo.structured-data - required - developer - proved by the build - one node per type per page, no errors

Emit one Organization or LocalBusiness node for the site and a BreadcrumbList on pages that have a breadcrumb trail. One node per type per page - a plugin plus a theme both emitting Organization is a validation error, not redundancy.

Without it, two Organization nodes give Google two different names for the brand, and nobody decides which one it takes.

Accepted when. Google's Rich Results Test reports no errors on the home page and one interior page, and exactly one Organization node exists per page with a name matching the brand.

Source. Google Search Central documentation, Google (vendor) - appearance/structured-data/intro-structured-data, in part - https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data

Multilingual sites declare reciprocal hreflang

seo.hreflang - required - developer - proved by the build - every version, plus itself, plus x-default, ISO codes only

Only when the site serves more than one language.

Emit hreflang alternates on every translated page, including a self-reference and an x-default, with codes that are ISO 639-1 languages and, where needed, an ISO 3166-1 alpha-2 region after a hyphen. Every alternate must point back, or search engines discard the whole set.

Without it, a visitor searching in French is served the English URL, and nothing in the markup looks broken because a discarded set is silent.

Accepted when. Each language version lists every other version plus itself plus x-default, following any alternate lands on a page that lists the original back, and every code is an ISO 639-1 language with at most an ISO 3166-1 region, never a locale such as fr_FR.

Source. Google Search Central documentation, Google (vendor) - specialty/international/localized-versions, in part - https://developers.google.com/search/docs/specialty/international/localized-versions

Every URL-minting template has a recorded index decision

crawl.indexability-decided - required - developer - proved by the build - one recorded decision per template, and the sitemap agrees

List every template that mints a URL - post types, taxonomy archives, author and date archives, attachment pages, search results, paginated tags - and every non-HTML route, feeds, PDFs, uploads, and record index or noindex against each. Apply the decision in the theme, not in a plugin checkbox; a non-HTML URL carries it as an X-Robots-Tag header. Anything undecided is indexed by default.

Without it, author, date and attachment archives nobody thought about get indexed by default, and thin near-duplicates compete with the real pages.

Accepted when. The handover carries a dated table of every URL-minting template and non-HTML route with its decision. For each marked noindex, one sampled URL answers noindex, by X-Robots-Tag where there is no HTML head, and appears in no sitemap.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 8, item 5, in part - not public

Structured data says what the page actually says

onpage.schema-fidelity - required - developer - proved by the build - type fits the page, values match the rendered copy

Read every value in the JSON-LD against the page it sits on. The headline is the editorial title, the author is a real person or organisation, the dates are the real ones, and wordCount, if you emit it, is the real count. Never emit an aggregateRating for reviews that only exist client-side. A validator checks the shape and can see none of this.

Without it, Google is told the article is 8 words long when 659 are on screen, and the validator that would catch a typo reports it all green.

Accepted when. On three sampled pages every emitted value matches the rendered page: headline equals the visible title, author is a name rather than a language code, dates match, and no rating, price or count is emitted that the page itself does not show.

Source. Google Search Central documentation, Google (vendor) - appearance/structured-data/sd-policies, in part - https://developers.google.com/search/docs/appearance/structured-data/sd-policies

The site ships a favicon Google can crawl

onpage.favicon - recommended - developer - proved by the build - square, at least 48 px, 200, not Disallowed

Set a site icon rather than shipping the WordPress default W. Google wants a square image, at least 8 pixels a side and larger than 48 recommended, referenced from a link element in the head, on a URL that robots.txt does not block. Ask the agency for the source icon at kickoff - it is a brand asset, and a build cannot invent one.

Without it, the WordPress W, or a blank globe, sits next to the brand in every mobile search result and every browser tab.

Accepted when. The home page head references an icon, that URL returns 200 with a square image at least 48 pixels wide, and robots.txt disallows neither it nor its directory. The WordPress default icon appears nowhere.

Source. Google Search Central documentation, Google (vendor) - favicon-in-search#guidelines, in part - https://developers.google.com/search/docs/appearance/favicon-in-search#guidelines

Content and language

Link text makes sense out of context

a11y.link-text - required - developer - proved by the build - never "here", "read more" or "click"

Write links that describe their destination. Screen reader users list links out of context, where twelve links called "read more" are twelve identical links. SC 2.4.4 at Level A asks only that the purpose be clear from the link plus its surrounding sentence; making it clear from the link ALONE is SC 2.4.9 at AAA, and that is the bar this rule sets.

Without it, a listing of twenty cards gives a screen reader twenty links all reading Dรฉcouvrir and all going somewhere different.

Accepted when. No two links on a page share the same text but different destinations, and no link text is only "here", "read more" or "click".

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 2.4.4; 2.4.9 (A, AAA), in part - https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html

The site publishes an accessibility statement

a11y.statement - recommended - developer - proved by the build - target, known gaps, a contact route

Publish a page stating the conformance target, known gaps and a contact route for accessibility problems. Under the EAA this is what a client is asked for first when challenged, and it is cheap to produce at handover and expensive to reconstruct later.

Without it, nobody can say what the site targets or where to report a problem, and the client has nothing to produce when a complaint arrives.

Accepted when. A linked, reachable page names the standard targeted, lists known exceptions, and gives a working contact address.

Source. Directive (EU) 2019/882 on the accessibility requirements for products and services (European Accessibility Act), EUR-Lex, Publications Office of the European Union (law) - Art. 13(2); Annex V(1), in part - https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32019L0882

Adding a block shows a picture first and never lands empty

colibrity.adding-a-block - recommended - developer - proved by the build - a rendered thumbnail per layout, real default copy

The block picker is the client's first contact with the build. Show each block as a picture of itself, rendered from the block and regenerated when it changes, so the editor chooses by sight rather than by name. Ship every block with real default copy, so an added block is a first draft rather than an empty box.

Without it, the editor adds four blocks by name to see what they look like and deletes three, every time.

Accepted when. Every layout in the picker shows a thumbnail rendered from the block itself, and the thumbnails are regenerated by one command. Adding any block to an empty page renders visible, sensible content before the editor types a word.

Ships in wp-starter/block-defaults.

Source. Delivered reference builds, audited together, Our own delivered work (house) - inc/admin-blocks.php and inc/block-defaults.php, the third build only - not public

Editable content is editable, not baked into templates

practices.no-hardcoded-content - required - developer - proved by the build - copy, images, links and order, from fields

Anything the client will plausibly want to change - copy, images, links, section order - comes from fields. Hard-coded strings turn every text tweak into a developer task and a deploy.

Without it, the client emails you to change a phone number, and a one-word fix costs a developer, a deploy and an invoice.

Accepted when. A content editor can change the text and images of every public page, and reorder the sections of a flexible page, without a developer.

Source. Delivered reference builds, audited together, Our own delivered work (house) - page.php loops have_rows(page_blocks), both builds - not public

Every dynamic component handles empty, long and broken

practices.empty-states - required - developer - proved by the build - zero items, one, many, long title, no image

Design for zero items, one item, many items, a very long title, a missing image and a failed request. Test with the real content, not the placeholder set that always has three items of equal length.

Without it, a filter that matches nothing leaves a blank strip on the page and the visitor decides the site is broken.

Accepted when. Emptying a listing shows a deliberate empty state rather than a blank region. A title of double the expected length does not break its layout.

Source. Delivered reference builds, audited together, Our own delivered work (house) - a rendered empty state per listing, both builds - not public

Titles and descriptions are present, unique and the right length

seo.title-description - required - developer - proved by the build - titles 30-60, descriptions 70-160, one of each per page

Give every template a title pattern and every published page a description. Titles run roughly 30 to 60 characters, descriptions 70 to 160. One of each per page: a theme and a plugin both emitting a description is a defect, not redundancy. Do not ship the theme defaults.

Without it, two templates share one title, so the results give no way to tell the pages apart and Google rewrites the title link itself.

Accepted when. No sampled page is missing a title or description, no two sampled pages share either, lengths sit in range, and each page carries exactly one title element, one description and one robots meta.

Source. Google Search Central documentation, Google (vendor) - appearance/title-link, in part - https://developers.google.com/search/docs/appearance/title-link

The heading outline reads as an outline

seo.headings-describe-content - recommended - developer - proved by the build

Write headings that describe the section beneath them. Headings chosen for their size are a styling decision wearing a structural tag.

Without it, a screen-reader user skimming the headings hears the brand name and a sentence fragment ending on a preposition, and learns nothing.

Accepted when. Reading only the headings of a page conveys what the page is about, in order, without needing the body copy.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 2.4.6 (AA), in part - https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels.html

Every language publishes the same set, or says what it leaves out

crawl.language-parity - required - developer - proved by the build - one parity report per language, gaps listed with a reason

Only when the site serves more than one language.

Measure each secondary language against the source language: published pages and posts, menu items, terms, and the block count of each translated page. A gap is filled or listed with a reason before launch, and a translation with fewer blocks than its source is a gap. Run the report from the content, not from the code: a green suite cannot see an empty menu.

Without it, the French site ships with no navigation for days while every gate in the project is green.

Accepted when. A dated parity report per secondary language lists zero unexplained gaps in published items, menus and terms, and every translated page carries the same block count as its source or a written reason.

Source. Delivered reference builds, audited together, Our own delivered work (house) - a parity checker in one older build and the third; nothing of the kind in the other older build - not public

Core CMS fields carry the content, not only the builder

onpage.core-fields - required - developer - proved by the agency - 0 published items with an empty title, excerpt or image

Fill the core fields - title, excerpt, featured image - on every published item, not only the page builder's own hero and intro fields. The SEO plugin, site search, the RSS feed, the og:description fallback and the schema headline all read the core fields, and none of them shows up in a rendered page, so a builder-only site fails all five silently.

Without it, site search returns blank rows and shared links show no description, while the page itself looks perfect to everyone checking it.

Accepted when. wp post list over each public post type returns 0 published items with an empty post_title, an empty post_excerpt or no featured image, run on the content-complete site and dated. A post type that deliberately uses none of the three is listed with its reason.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 8, item 12 - not public

The site's identity is set on purpose and the share card is seen

onpage.site-identity - required - developer - proved by the build - title, tagline, separator, sameAs set; card seen in 3 apps

Set the site title, the tagline and the title separator once, in the SEO plugin, and check that no page prints the WordPress default tagline. List every official brand account in the Organization node's sameAs. Then render the share card for the home page and one article in the Facebook, LinkedIn and iMessage previews, and fix what each shows before launch.

Without it, the client's first share reads Just another WordPress site, and the profile links Google shows beside the brand belong to nobody.

Accepted when. The home page prints a deliberate title and tagline, every page uses one separator, the Organization node lists each official account in sameAs, and a dated screenshot of the home and one article preview exists for each of the three platforms.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 8, item 4, in part - not public

The admin reads in the language the editor set

admin.editor-language - recommended - developer - proved by the build - labels follow wp-admin's language, keys never do

Only when the site serves more than one language.

On a multilingual project, switch the admin's own labels with the language wp-admin is displayed in, not with the language of the content being edited. Post type labels, list table columns and meta box rows all read in whatever language the editor chose. Slugs, meta keys and field names stay in one language and are never translated: they are identity, and a translated key is data loss.

Without it, labels follow the content-language switcher instead of the editor's own, so half the meta boxes stay in a language they cannot read.

Accepted when. Switching the wp-admin display language switches the labels of the site's own post types, columns and meta boxes. No slug, meta key or field name changes with it.

Source. Delivered reference builds, audited together, Our own delivered work (house) - bilingual label helper, one build of the two - not public

An editor can duplicate any content item, translations included

admin.duplicate-content - required - developer - proved by the build - one action, one draft per language, no linked twin

Give every content type the editor works in a Duplicate action. The copy is a draft carrying the original's fields, terms and featured image, owned by whoever clicked, with no slug until it is published. On a multilingual site one action copies every language from its own original into one new translation group, never a plugin twin the source overwrites on save.

Without it, the editor rebuilds a forty-field page by hand, or clones it with a plugin whose copy the original silently overwrites on the next save.

Accepted when. Duplicate one item of each editable type: a draft appears with the original's fields, terms and image, and publishing it changes nothing on the original. On a multilingual site one action yields one draft per language and none of them carries a link back to the source.

Ships in wp-starter/duplicator.

Source. Delivered reference builds, audited together, Our own delivered work (house) - inc/duplicate-core.php, the third build; a prefetch guard only in one of the two - not public

Every field and every fixed string says how it translates

admin.translation-declared - required - developer - proved by the build - a preference per field, zero source-language theme strings

Only when the site serves more than one language.

Where a custom field is registered, declare how it travels between languages: translated, copied once, or copied on every save. A field with no preference gets whatever the plugin defaults to that week. Route every fixed string the theme prints, labels, empty states, form messages, through the translation plugin's string table, so an editor changes it without a deploy.

Without it, a translated page keeps the source language in every label and empty state, and a copied field overwrites the translation on the next save.

Accepted when. A scan over the field definitions finds every registered field carrying a translation preference. Each template loaded in a secondary language shows zero theme strings in the default language, and any one of them can be changed from the admin.

Source. Delivered reference builds, audited together, Our own delivered work (house) - a preference on every field, all three builds; the string table in two of three - not public

The 404 and 500 pages are designed, not server defaults

states.error-page-design - required - developer - proved by the build - site chrome, a route back, no raw server 500

Both pages render inside the site's own header, footer and type styles, and give a route back into the site. wp-starter's 404.php is a styled stub with no search and no suggested links, and there is no 500 or maintenance template at all, so anything past that stub is project work. Decide what a PHP fatal shows before launch, not after one.

Without it, a PHP fatal drops the visitor onto WordPress core's unstyled critical-error screen, with no header and no way back.

Accepted when. The 404 opens with the site header, footer and type styles and at least one working link back into the site. Forcing a 500 on staging returns a page in the same chrome, not the raw server or PHP default.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 3, item 11, in part - not public

A missing URL returns a real 404 in the right language

states.error-pages - required - developer - proved by the build - 404 status on a missing URL, correct lang attribute

Answer a URL that does not exist with a 404 status, never a 200 carrying an error message. A soft 404 keeps the missing page in the index and hides the fault from every monitoring tool. On a multilingual site the 404 under /fr/ carries lang="fr" and French copy. This applies to a new build as much as to a relaunch, where the SEO volume's redirect map covers it.

Without it, a missing URL answers 200, so Search Console files it as a soft 404 and no monitoring tool ever flags the broken link.

Accepted when. Request a made-up path under each language root: every one answers 404, not 200 and not 302. The HTML returned carries that language's lang attribute and that language's copy.

Source. Google Search Central documentation, Google (vendor) - http-network-errors#2xx-success, in part - https://developers.google.com/search/docs/crawling-indexing/http-network-errors#2xx-success

Performance

LCP at or under 2.5 seconds on mobile

perf.lcp - blocker - developer - proved by the build - 2.5s or less, median of 3 on mobile

Identify the LCP element before touching payload. It is usually being discarded rather than loading slowly - an element that finishes loading while hidden is dropped permanently, and opacity zero, clip-path and overflow hidden are all treated the same way. You cannot fade in your largest above-the-fold element and have a fast LCP.

Without it, a faded-in hero is dropped from the running and something slower below becomes the metric, so shaving bytes off the hero changes nothing.

Accepted when. Median of three or more mobile runs is 2.5s or less, and the identified LCP element is stated in the report alongside the number.

Source. Web Vitals, Google (web.dev) (vendor) - lcp, in part - https://web.dev/articles/lcp#what-is-a-good-lcp-score

CLS at or under 0.1

perf.cls - blocker - developer - proved by the build

Reserve space for everything that arrives late: width and height on every image and video, explicit dimensions on embeds and ads, and font fallbacks metric-matched to the web font.

Without it, a reader taps one link and lands on another, because a late image or web font shoved the page down under their thumb.

Accepted when. Median mobile CLS is 0.1 or less, and no visible shift occurs during a slow-network reload watched by eye.

Source. Web Vitals, Google (web.dev) (vendor) - cls, in part - https://web.dev/articles/cls#what-is-a-good-cls-score

TBT at or under 200ms and INP at or under 200ms

perf.inp-tbt - required - developer - proved by the build

Keep the main thread free during load. Defer non-critical scripts, break long tasks, and keep third-party tags off the critical path.

Without it, a tap on the burger does nothing while the main thread finishes parsing script, so people tap twice and open then close the menu.

Accepted when. Median mobile TBT is 200ms or less. Where field data exists, INP at the 75th percentile is 200ms or less.

Source. Web Vitals, Google (web.dev) (vendor) - inp, in part - https://web.dev/articles/inp#good-score

Mobile performance score at or above 90

perf.score-floor - required - developer - proved by the build

Treat the score as a summary of the metrics above, not a target of its own. Chasing the number rather than the metric is how a site ends up fast in a lab and slow for people.

Without it, the client runs PageSpeed themselves a week after launch, gets a red number, and there is no recorded median to answer it with.

Accepted when. Median of three or more mobile runs is 90 or above, quoted with the spread of the runs.

Source. Lighthouse performance scoring, Google (Chrome for Developers) (vendor) - performance/performance-scoring, in part - https://developer.chrome.com/docs/lighthouse/performance/performance-scoring

JavaScript and CSS stay inside their budgets

perf.bundle-budgets - blocker - developer - proved by the build - a gzip budget per chunk, failing the build

Assert a gzip budget per chunk in the build and fail the build when it is exceeded. Raising a budget is allowed, in the same commit, with a written note saying what the extra bytes bought.

Without it, a bundle doubles across a year of small commits, each too small to notice, and no single one is blameable once the site is slow.

Accepted when. The budget test passes, or a raised budget is accompanied by that note in the commit. Without repository access this is reported as unverified, never as a pass.

Ships in wp-starter/bundle-size-test.

Source. PERFORMANCE.md, wp-starter, our own repository (house) - CONVENTIONS.md:133-134, in part - not public

Every deliberate split point is asserted in the build

perf.split-points-asserted - required - developer - proved by the build - paired chunk-graph assertions, no module in two chunks

Where a heavy dependency is kept off a critical path by a dynamic import, assert the shape of the built graph rather than trusting it. A bundler exits 0 on a specifier it cannot resolve, so a broken split builds green and smaller. Assert in pairs: negative, that the critical entry cannot reach it, and positive, that the chunk meant to load it still can. Assert also that no module lands twice.

Without it, a mistyped dynamic import makes the build smaller and greener while the feature it was meant to load is quietly dead in production.

Accepted when. The build fails when the negative assertion is broken and fails when the positive one is, each confirmed by breaking it on purpose. The assertion reads the build manifest, not the bundler's exit code, and no module appears in two chunks.

Source. Delivered reference builds, audited together, Our own delivered work (house) - bin/assert-chunks.mjs, one build of the two - not public

Nothing render-blocking that does not have to be

perf.no-render-blocking - required - developer - proved by the build - defer or type=module, own critical CSS only

Scripts are deferred or type module. Only above-the-fold CSS is render-blocking. No synchronous third-party script in the head. No font stylesheet from another origin blocking first paint.

Without it, a blank white page for as long as another company's font server takes to answer, and longer still on the day it is down.

Accepted when. The render-blocking resources audit lists nothing beyond the site's own critical stylesheet.

Source. Web Vitals, Google (web.dev) (vendor) - optimize-lcp, in part - https://web.dev/articles/optimize-lcp#defer_or_inline_render-blocking_javascript

Static assets are compressed and cached far-future

perf.caching-and-compression - required - developer - proved by the build - br or gzip, one year immutable on hashed

Confirm what the host actually sends rather than assuming. Some hosts compress nothing, in which case every estimate made from a gzipped size is wrong. Fingerprinted assets take a one-year immutable cache header.

Without it, a returning visitor re-downloads a byte-identical hashed bundle on every page view, paying for files that could not have changed.

Accepted when. Content-Encoding is br or gzip on HTML, CSS and JS, and hashed assets carry a cache lifetime of a year with immutable.

Source. Web Vitals, Google (web.dev) (vendor) - http-cache, in part - https://web.dev/articles/http-cache#versioned-urls

Third-party scripts are counted and justified

perf.third-party - recommended - developer - proved by the build - under 250ms combined main-thread time

List every third-party origin the page loads and what it is for. Load analytics and chat after interaction or after load. Anything nobody can name an owner for comes out.

Without it, a Meta pixel nobody on the team added eats most of the main-thread budget, and no one can say who owns it or whether it may go.

Accepted when. The third-party summary is reviewed, each origin has a stated purpose, and their combined main-thread time stays under 250ms.

Source. Web Vitals, Google (web.dev) (vendor) - third-party-javascript, in part - https://web.dev/articles/third-party-javascript#what_to_do_about_it

Every asset loads once, has content, and only where used

perf.enqueue-hygiene - required - developer - proved by the build - no asset requested twice, no 0-byte file, 4 hints max

Open one network log per template and read it. No file requested twice, no near-empty stylesheet or script, no library loaded by a page that does not use it, and at most four preconnect or dns-prefetch hints. Past four, hints compete with the requests they should be speeding up. Nothing in the starter theme does this for you: it has one Vite entry and no per-block enqueue layer.

Without it, the same file is fetched twice on one page load, and each request looks reasonable on its own until somebody reads the whole log.

Accepted when. In each sampled template's network log: zero URLs requested more than once, no stylesheet or script under 100 bytes, no library loaded by a template that does not use it, and four or fewer resource hints in the head.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 10, item 5, in part - not public

Optimiser output is regenerated and state classes safelisted

perf.optimiser-output-fresh - required - developer - proved by the build - every JS-toggled class in prod CSS, critical CSS rebuilt

Safelist every class that only JavaScript ever adds, before the CSS optimiser runs, or the state it styles ships with no rules behind it. Rebuild the critical CSS in the same step as the bundle so it can never describe an older layout. Both faults look fine locally, because the optimiser only runs in the production build.

Without it, the menu opens perfectly on staging and unstyled on the live site, once Remove Unused CSS strips the class only JavaScript adds.

Accepted when. Every class toggled from JavaScript is present in the production stylesheet: open the menu, a modal and one filter on the live URL and confirm the styling applies. The critical CSS is no older than the build it shipped with.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 10, item 7 - not public

Each template has a transfer and DOM budget

perf.page-weight - required - developer - proved by the build - under 2 MB without video, 1400 elements or measured

Give each template a budget and check it before launch: under 2 MB of transfer on first load, video excluded, and under 1400 DOM elements. Video keeps its own rule because one background clip swallows the whole budget. A template whose length is set by how much content it holds may pass the element half by measurement instead, on the largest page it renders.

Without it, a template renders 2774 DOM elements against a 1400 budget, and every style recalc and scroll handler pays for all of them.

Accepted when. First-load transfer is under 2 MB per sampled template, video excluded, and anything over names its three largest resources. Element count is under 1400, or over it with a median mobile TBT at or under 200 ms recorded on the largest page that template renders.

Source. Technical SEO control grid, A third-party SEO agency, commissioned by Colibrity (client) - rows 134, 139, in part - not public

Server response starts inside 800 ms

perf.ttfb - required - developer - proved by the build - TTFB 800 ms or less, median of 3, mobile

Get the server answering before optimising anything the browser does. Cache the HTML, keep query work out of the request, and check the host is not adding a redirect hop before the document. A 900 ms first byte makes a 2.5 s LCP arithmetically hard, and no amount of image work buys it back.

Without it, a second passes before the browser holds a single byte, and a week of image work cannot buy back what the server already spent.

Accepted when. The server response time audit reports 800 ms or less as the median of three or more mobile runs on the slowest template, with the spread of the runs quoted.

Source. Web Vitals, Google (web.dev) (vendor) - ttfb, in part - https://web.dev/articles/ttfb#good-ttfb-score

Responsive and devices

The page survives 200 percent zoom and a 320px viewport

a11y.zoom-and-reflow - required - developer - proved by the build - no loss at 200%, no h-scroll at 320px, body text 16 px+

Set text-only zoom to 200 percent and check nothing clips or overlaps. At 320 CSS pixels wide there must be no horizontal scrolling of the page body. Body text computes to 16 pixels or more at every width; 14 px copy is the first thing zoom gets used for.

Without it, body overflow-x hidden suppresses the measurable instead of passing it, and the clipped content is unreachable, not just off-screen.

Accepted when. At 200 percent zoom no content is lost or overlapping, at 320px wide the page scrolls vertically only, and body text computes to at least 16 px at every sampled width.

Source. Web Content Accessibility Guidelines (WCAG) 2.1, W3C (standard) - 1.4.10; 1.4.4 (AA), in part - https://www.w3.org/WAI/WCAG21/Understanding/reflow.html

The layout holds at every width, not just the design widths

practices.responsive-breakpoints - required - developer - proved by the build - 320 to 2560, no h-scroll, no overlap

Drag the window slowly from 320 to 2560 and watch for overlap, clipping, orphaned words and horizontal scroll. Designs are drawn at three widths; visitors use every width.

Without it, a 900px laptop sits between two drawn widths, and nobody opens one before launch, so that is where the heading lands on the image.

Accepted when. No horizontal scrollbar at any width from 320 up, no clipped or overlapping content, and no layout that only works at the exact design breakpoints.

Source. Delivered reference builds, audited together, Our own delivered work (house) - one breakpoint map plus body overflow-x hidden, both builds - not public

Anything animated or video-driven is checked on a real device

practices.real-device-check - required - developer - proved by the build - a real iPhone and a real mid-range Android

Open the site on a real iPhone and a real mid-range Android. Simulators do not reproduce Safari's video behaviour, momentum scrolling, or the cost of an animation on a slow GPU.

Without it, the hero video plays in every simulator and stays black on the client's iPhone, where iOS refuses play() outside a real gesture.

Accepted when. Every page with entrance animation or video is opened on a real phone in a fresh private tab, and scrolling stays smooth with video playing.

Source. Delivered reference builds, audited together, Our own delivered work (house) - a real-device debugging procedure in .claude/, both builds - not public

The layout survives the phone's real viewport

practices.mobile-viewport-chrome - required - developer - proved by the build - nothing clipped by the toolbar or notch, landscape usable

Check a real phone in both orientations, not a narrow desktop window. Full-height sections want dvh or svh rather than vh, so the browser toolbar cannot cut one off, and edge content wants safe-area inset padding so nothing hides under the notch. In landscape a sticky header should not take more than 15 percent of the height. wp-starter has dvh and svh but no safe-area handling.

Without it, the phone's home indicator sits on top of the fixed CTA bar, and the visitor cannot tap the one button that matters.

Accepted when. On a real phone at 390x844: no section clipped by the browser toolbar in either orientation, no text or control under the notch or home indicator, and a sticky header at 15 percent of viewport height or less in landscape.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 11, item 5, in part - not public

Email, CRM and analytics

Every form field lands in the right CRM field

comms.crm-field-map - blocker - ops - proved by the client - a dated signed field map, plus one test record read back

Only when the project has a form that collects data from a visitor, and submissions go to a CRM or another third-party system.

Agree a field map before launch - each form field, the CRM field it lands in, its type, whether it is required - and have it signed and dated. wp-starter ships no CRM connector, only the wps_form_submitted seam, so the integration is project code. Log every outgoing payload with a timestamp, so the send stays provable when nobody will grant CRM access.

Without it, a lead arrives in the CRM with its phone number in a notes field and its consent value missing, and nobody can prove where it went.

Accepted when. Submit one test entry. Either open it in the CRM and check every field on the signed map is in its named field, or read the dated log line showing that payload leaving with the agreed field names. Keep whichever you used.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 13, item 4, in part - not public

Automated mail arrives with SPF, DKIM and DMARC passing

comms.email-authentication - blocker - ops - proved by the client - all three pass on one dated test send

Only when the site sends email of its own - notifications, autoresponders, transactional.

Publish SPF and DKIM for the address the site sends from, then DMARC at p=none once both pass. Gmail and Yahoo require SPF or DKIM of every sender, and all three only of bulk senders at 5000 messages a day - a contact form is not bulk, so DMARC here is our bar, not theirs. Ask at kickoff who holds the DNS zone: if Colibrity holds it, this rule is agency-owned.

Without it, Gmail and Yahoo drop unauthenticated mail into spam or refuse it outright, so the enquiry is lost and nobody gets a bounce.

Accepted when. Send the live form to a Gmail account and open Show original. The Authentication-Results header reads spf=pass and dkim=pass, and dmarc=pass once the record is published. That dated header is the evidence, filed with the handover.

Source. Email sender guidelines, Google (Gmail Help) (vendor) - Requirements for all senders, in part - https://support.google.com/mail/answer/81126#requirements-all

A submission is stored before anything is emailed

comms.submission-delivery - blocker - developer - proved by the build - with mail broken on purpose, the record still lands

Only when the project has a form that collects data from a visitor.

Write the submission to the database first, then send the notification and push to the CRM. A mail or CRM failure becomes a flag on a stored record, never a failed submission and never a lost lead. wp-starter drives the store and the mail from one field map but does not guarantee the order, so check it. The consent value travels with the record to the CRM.

Without it, one SMTP failure takes the whole enquiry with it, leaving no record, no consent proof and nothing to replay once mail works again.

Accepted when. Break mail sending on purpose and submit once. The entry is in the admin list inside a minute, flagged as not notified, and it carries the consent value that goes on to the CRM. Nothing is lost when mail fails.

Source. Delivered reference builds, audited together, Our own delivered work (house) - inc/rest-contact.php stores before mailing, both builds - not public

The events that count are agreed, fired once, and observable

comms.conversion-events - required - developer - proved by the agency - an agreed list, each firing exactly once per action

Only when the project loads analytics, advertising or marketing tags.

Agree with Colibrity and the client which actions count as a conversion, and write the list down with the event name each one sends. wp-starter maps seven theme events to GA4 and Meta and dedupes the server copy by a shared id, so a pair counts once. Its browser twin is queued in a request that renders no page, so dispatch it client-side or the visitor never sends it.

Without it, analytics shows traffic and never a single lead, because a fetch-submitted form fires no page load for a tag to hang on.

Accepted when. The agreed list is written down and dated by Colibrity. Doing each action once shows exactly one event of that name in GA4 DebugView, including after two in-page navigations, and the Meta copy is not counted twice.

Ships in wp-starter/conversion-events.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 14, item 2, in part - not public

A broken form or CRM connection raises an alert

comms.delivery-alerting - required - ops - proved by the agency - a failed send alerts a named human inside one day

Only when the site sends email of its own - notifications, autoresponders, transactional.

Route every bounced notification and every CRM error to somewhere a person looks - a named inbox, or a channel with an owner. Never a silent retry, never a log file only. Name that person in the handover with the date they take it on. If Colibrity will not hold the inbox after handover, record it as the client's from that date.

Without it, a broken credential eats enquiries into a log file nobody reads, and the first to notice is the client asking why nobody called back.

Accepted when. Break the CRM credential on purpose and submit once. An alert naming the site and the failure reaches the named recipient within 24 hours, and the handover names that recipient and the date they took it on.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 13, item 5, in part - not public

Transactional emails render in the clients people use

comms.email-rendering - required - developer - proved by the build - Gmail, Outlook and iPhone Mail, plus a text part

Only when the site sends email of its own - notifications, autoresponders, transactional.

Send every automated email to a real Gmail, Outlook and iPhone Mail account and read it in each one. Keep the HTML under 102 KB, because Gmail clips past that and hides the end behind a link. Always send a plain-text part alongside. wp-starter has no templating layer - bodies are concatenated strings - so anything richer is project code you own and test.

Without it, an Outlook reader gets a broken stack of tables and a Gmail reader gets the end of the message hidden behind a clipping link.

Accepted when. The same message is opened in Gmail, Outlook and iPhone Mail, with a dated screenshot of each: no broken layout, no clipping notice, every link working. The raw source carries a text/plain part.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 13, item 2, in part - not public

Automated email carries a correct From, Reply-To and language

comms.sender-identity - required - developer - proved by the build - From on the site's domain, Reply-To a real inbox

Only when the site sends email of its own - notifications, autoresponders, transactional.

Send From an address on the site's own domain. Never set From to the visitor's address - that breaks SPF and DMARC and is the commonest reason a form mail is rejected. Put the visitor in Reply-To on the notification, and a monitored inbox in Reply-To on the autoresponder. Send each locale's mail in that locale's language, subject line included.

Without it, an English visitor gets a French autoresponder, and a From on a domain the site does not own fails SPF before anyone reads it.

Accepted when. In the received notification and autoresponder, From is on the site's own domain, Reply-To resolves to an inbox someone reads, and a submission from each locale arrives written in that locale's language, subject included.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 13, item 3, in part - not public

Pre-launch QA

No automated accessibility violations

a11y.axe-clean - blocker - developer - proved by the build - axe, zero violations at WCAG 2.1 A and AA

Run axe over every template and fix everything it reports. Treat a clean run as the starting line for the manual checks below, not as evidence of accessibility.

Without it, the cheap third of WCAG failures ships anyway, and a Lighthouse score of 98 gets read as a pass it never was.

Accepted when. axe reports zero violations at the WCAG 2.1 A and AA rule sets across the sampled pages, including one page with a form and one with a listing.

Source. EN 301 549 - Accessibility requirements for ICT products and services, ETSI, CEN and CENELEC (standard) - 9.6, in part - https://www.etsi.org/deliver/etsi_en/301500_301599/301549/03.02.01_60/en_301549v030201p.pdf#page=51

One page and one form are driven with a screen reader

a11y.screen-reader-pass - required - developer - proved by the build - VoiceOver or NVDA, one content page plus one form

Open one content page and one page with a form in VoiceOver on macOS or NVDA on Windows, and drive both without looking. Move by heading, by landmark and by link, then submit the form with an error in it. This is the pass that finds what a scanner cannot: an announcement order that makes no sense, a control named after a file, an error message nobody hears.

Without it, nobody has driven the site by ear even once, so an announcement order that makes no sense ships with every automated check green.

Accepted when. The report names the screen reader and browser used, lists the headings and landmarks in the order they were announced, and confirms the invalid submit was announced. Zero unlabelled controls were met on the two pages.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 12, item 9, in part - not public

Every template produces valid HTML

html.valid-markup - required - developer - proved by the build - W3C validator, zero errors, every template

Run the W3C validator over a sample covering every template, not just the home page. Unclosed elements and duplicate ids break assistive technology and JavaScript long before they look wrong.

Without it, browsers each guess a different repair for broken markup, so the bug reproduces on the client's machine and not on yours.

Accepted when. The validator reports zero errors on the sampled pages. Warnings are read and either fixed or noted with a reason.

Source. HTML Living Standard, WHATWG (standard) - 2.1.8 Conformance classes, in part - https://html.spec.whatwg.org/multipage/infrastructure.html#conformance-checkers

Production loads with a clean console

practices.console-clean - required - developer - proved by the build - no error, no rejection, no failed request

Clear every error and warning, remove debug logging, and make sure no asset 404s. A console full of noise is where the next real error goes unnoticed.

Without it, the asset 404 that broke the client's tracking sits unread for months underneath the debug logging.

Accepted when. Loading each sampled page and navigating between two of them produces no console error, no unhandled rejection and no failed network request.

Source. CONVENTIONS.md, wp-starter, our own repository (house) - PERFORMANCE.md:267-276, in part - not public

The theme's contracts are tests, and a deploy runs them first

practices.tests-before-deploy - required - developer - proved by the build - a suite in the repo, run by the deploy, red means no upload

Pin every mechanism the handover relies on with a test in the repository: budgets, split points, environment gates, comments off, noindex on the developer pages. Run the suite from the deploy script, after the build and before the upload, and stop on red. A hook that only formats code is not a gate, and a test that skips when the build is missing is not a pass: build first, then test.

Without it, a green suite that no deploy ever runs ships the broken split point it exists to catch.

Accepted when. The deploy script runs the build, then the suite, then the upload, and exits on a red suite. The suite reads the built output rather than skipping it, and each mechanism the handover names has a test that fails when the mechanism is removed.

Source. Delivered reference builds, audited together, Our own delivered work (house) - a suite in the third build and the starter, run by no deploy script in any build; nothing in the two older builds - not public

No internal link is broken

seo.internal-links-resolve - required - developer - proved by the build - no 404, no 5xx, no chain over one hop

Crawl internal links before handover. Broken links usually come from content entry and trailing-slash mismatches, not from templates, so this has to run against real content.

Without it, a published page sits twice in the main menu and 404s from every template on the site, reaching the sitemap before anyone notices.

Accepted when. A crawl of the sampled pages returns no 404 and no 5xx on internal links, and no chain of more than one redirect.

Source. Google Search Central documentation, Google (vendor) - crawling/docs/troubleshooting/http-status-codes, in part - https://developers.google.com/crawling/docs/troubleshooting/http-status-codes

Canonical, robots, sitemap and meta tell one story

crawl.signals-agree - blocker - developer - proved by the build - 0 URLs both noindex and Disallowed, 0 conflicts in 10

A Disallowed URL is never fetched, so its noindex is never read and the page stays in the index. To remove a page: serve noindex, wait for it to drop out of the Coverage report, and only then Disallow it. Never both at once. On any one URL the canonical, the meta robots, the X-Robots-Tag and sitemap membership have to point the same way.

Without it, the page you meant to drop stays indexed for good: robots.txt stops the fetch, so the noindex that would have removed it is never read.

Accepted when. On ten sampled URLs, none is both noindex and Disallowed in robots.txt, no noindex URL is listed in a sitemap, and no page canonicalises to a URL that answers noindex, a redirect or a 404.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 7, item 13 - not public

Every indexable page is reachable by a static HTML link

crawl.internal-reachability - required - developer - proved by the build - 0 orphans, 3 clicks max, crawled with JavaScript off

Crawl from the home page with JavaScript off and diff the result against the sitemap. Every indexable URL has to be reachable in three clicks or fewer from a real anchor in the served HTML. A menu, listing or map that only renders after JavaScript is not a crawl path - one built that way hid 102 pages for a month - and a sitemap entry is a discovery hint, not a path.

Without it, whole page sets stay orphaned behind a JavaScript map - live in the CMS, listed in the sitemap, reachable by no anchor a crawler can follow.

Accepted when. The raw-HTML crawl reaches every URL in the sitemap: 0 orphans. No indexable page sits more than 3 clicks from the home page. A page found only in the sitemap counts as an orphan, not as reachable.

Source. Technical SEO control grid, A third-party SEO agency, commissioned by Colibrity (client) - row 7 - not public

A renamed slug leaves a redirect from its old URL

crawl.slug-change-redirect - required - developer - proved by the build - every renamed URL: one 301 to a 200

WordPress only self-heals a renamed post or custom post type, through the _wp_old_slug meta. Pages and every other hierarchical type get nothing, and neither does a changed parent, a renamed taxonomy term or a changed permalink structure. Save a dated copy of the sitemap before any restructure, then add a 301 for every URL that moved to the committed redirect file.

Without it, a renamed page 404s where a renamed post would have self-healed, and every external link and ranking pointing at the old URL dies with it.

Accepted when. Every URL in the sitemap snapshot taken before the restructure, plus every old URL rebuilt from _wp_old_slug, returns 200 or exactly one 301 to a 200. 0 of them 404, and 0 chains of two hops or more.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 8, item 24, in part - not public

Every published URL answers at its own address

crawl.published-urls-resolve - blocker - developer - proved by the build - 0 non-200 across published URLs and nav links

Ask the CMS for every published URL, add every navigation link, and request the lot against the built site. A page can be published, sitemapped, linked from the main menu and still answer 404, because the template that would render it does not exist and the hierarchy fell through to a catch-all that returns one. Nothing else reports this: it is not a broken link, it is a broken page.

Without it, a menu item the client clicks on launch day lands on a 404, and no link checker reports it because the link itself is fine.

Accepted when. Every published URL and every navigation link answers 200, following at most one redirect. Anything answering 404 or 410 is given a template or unpublished before launch. Drafts and private posts are out of scope.

Source. Delivered reference builds, audited together, Our own delivered work (house) - a nav item published, sitemapped and 404 on a live build, 2026-08-25 - not public

No placeholder content survives to launch

delivery.real-content - blocker - developer - proved by the build - 0 lorem, TODO or demo-asset matches across the sitemap

Sweep every URL in the sitemap for lorem ipsum, TODO, FIXME, placeholder image names and the starter theme's demo assets, and clear what you find. That sweep is the build's half of this item. Proofreading the real French copy is Colibrity's, and it is agreed as their sign-off rather than quietly implied here.

Without it, the client finds our lorem in the hero on launch day, and Google has already indexed the page carrying it.

Accepted when. A grep across every URL in the sitemap returns 0 matches for lorem, ipsum, TODO, FIXME and placeholder, and 0 references to a starter-theme demo image. Drafts and private posts are out of scope.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 15, item 1, in part - not public

Every template is opened in all three browser engines

delivery.browser-matrix - required - developer - proved by the build - Blink, Gecko and desktop WebKit - three passes

Open every template in Chrome, Firefox and desktop Safari. That is three engines, not five browsers: Edge is Blink, so testing it as well buys almost nothing. iOS Safari is a separate pass and belongs to the real-device check, because iOS ships its WebKit with the operating system.

Without it, a Firefox-only break ships because every check ran in Chromium, and the client's own team is the one that finds it.

Accepted when. Every template on the template list has been opened in Chrome, Firefox and desktop Safari - three engines, none checked in fewer - and each difference found is either fixed or written down.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 15, item 4, in part - not public

Scheduled tasks actually fire on the production host

ops.cron-runs - required - ops - proved by the build - no scheduled event overdue past its interval

WP-Cron only runs when someone visits, so on a quiet site scheduled publishing, cleanup, image conversion and any backup job stop silently. Add a real cron entry on the host and set the matching constant. Then check for overdue events rather than for the constant: code that quietly does the work inline when cron is off hides the fault instead of reporting it.

Without it, a scheduled post on a quiet site publishes whenever the next visitor happens to arrive, or not at all that day.

Accepted when. The production site's scheduled event list shows no event whose next run is in the past by more than its own interval, read after the site has been idle rather than immediately after a visit.

Source. WordPress Developer Resources, WordPress (vendor) - plugins/cron/hooking-wp-cron-into-the-system-task-scheduler, in part - https://developer.wordpress.org/plugins/cron/hooking-wp-cron-into-the-system-task-scheduler/

The site has been restored and rolled back, once, on purpose

ops.recovery-drill - required - ops - proved by the build - restore and rollback both run, dated, output kept

Before go-live, restore the newest off-host backup into a non-production target and bring the site up on it, then take one deploy back to its previous release. Save the terminal output of both runs with their dates. Nothing in the theme does this for you, and nothing stops the line being ticked without the work, so the evidence is the output and not the claim.

Without it, the first restore anybody attempts is during a real outage, and that is when the rollback turns out not to exist.

Accepted when. The handover carries dated terminal output from two runs: a restore of a backup no more than 7 days old into a non-production target, ending with that target's home page returning 200, and a rollback to the previous release. A tick with no output does not settle it.

Source. Hardening WordPress, WordPress (vendor) - security/brute-force#hardening, in part - https://developer.wordpress.org/advanced-administration/security/brute-force/#hardening

A destructive deploy refuses to run against the wrong target

ops.deploy-target-pinned - required - ops - proved by the build - the resolved target is checked, not just typed

Anything that can delete on a remote - an rsync with --delete, a database import - resolves its target, compares it to a pinned value and refuses when they disagree. A typed confirmation is not that check: it confirms intent, never destination. The destination comes from an SSH alias in a file the script cannot see, so the one thing nobody verifies decides where the delete lands.

Without it, an rsync --delete aimed at the wrong alias empties a live site's files, and the typed confirmation was answered correctly.

Accepted when. Pointed at the wrong target on purpose, the production deploy refuses and exits non-zero before transferring anything. A prompt that accepts a typed word and proceeds does not satisfy this.

Source. Delivered reference builds, audited together, Our own delivered work (house) - PROD_EXPECTED_URL_HOST, one build of the two - not public

No response leaks a PHP error, a stack trace or a server path

security.no-error-disclosure - required - developer - proved by the build - 500 status, no path, trace or error text

Turn error display off on staging and production and log to a file instead. Add an error drop-in that renders a plain 500 page saying nothing about the failure. A fatal that prints its trace hands over the absolute path of the install, the plugin that broke, the function that called it and often a query - a map for whoever asked for it.

Without it, the first fatal on the client's site prints a page of PHP at a visitor and a map of the install at whoever asked for it.

Accepted when. No sampled page's HTML contains Fatal error, Warning:, Notice:, Stack trace or an absolute server path, a deliberate fatal on staging returns HTTP 500 carrying none of those five strings, and wp config get WP_DEBUG_DISPLAY returns false or is unset.

Source. OWASP Application Security Verification Standard 5.0.0, OWASP Foundation (standard) - 16.5.1 (L2), in part - https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x25-V16-Security-Logging-and-Error-Handling.md#v165-error-handling

Comments are closed at every layer unless the brief asks for them

security.comments-closed - required - developer - proved by the build - closed on every existing and new item, no form rendered

WordPress ships comments on. Unless the brief asks for them, close them at every layer: remove the post type support so nothing new offers them, make the open check answer closed for every row already stored open, and take the Comments screen out of the admin. Removing the menu alone leaves every existing post open and the comment form rendering to the crawler and the spammer.

Without it, the first spam comment lands a month after launch on a feature nobody built and nobody moderates.

Accepted when. On every public post type, comments read as closed on an existing item and on a new one, asked through WordPress rather than read off the stored status. No template prints a comment form and the Comments screen is gone. Where the brief wants them, the handover names a moderator.

Ships in wp-starter/comments-off.

Source. Delivered reference builds, audited together, Our own delivered work (house) - closed in all three builds; in code only in the third, by configuration in the others - not public

Launch and handover

Developer surfaces stay, and their exposure is decided in code

colibrity.dev-surfaces-hidden - required - developer - proved by the build - noindex, no sitemap, hidden from the client, gated live

Every build ships developer surfaces: a block sandbox, a styleguide, a status page. Keep them, they are how a block is debugged after launch, and decide their exposure in code rather than by deleting them: noindex and out of every sitemap on every host, absent from the client's page lists, and on production behind a gate. Hidden by environment, not by capability, and the file says which.

Without it, a sandbox page ranks for the client's brand with placeholder copy on it, and the client finds it first.

Accepted when. Each developer route answers noindex in the meta robots or the X-Robots-Tag, appears in no sitemap, and is absent from the Pages list for a client account. On production an anonymous request to it is refused or redirected.

Ships in wp-starter/dev-pages.

Source. Delivered reference builds, audited together, Our own delivered work (house) - dev-pages-noindex.php in two of three builds; the production gate in the third - not public

The project ships with the notes needed to run it

practices.handover-notes - required - developer - proved by the build - one page in the repo, not a chat thread

Write down how to build and deploy it, where the environment values live, which plugins are load-bearing, what was deliberately left out, and the indexability decision for filters. One page in the repository, not in a chat thread.

Without it, somebody deactivates a plugin the theme quietly depends on, because no page anywhere says which ones are load-bearing.

Accepted when. Someone who has never seen the project can build, run and deploy it from that page alone.

Source. Delivered reference builds, audited together, Our own delivered work (house) - README.md plus a .claude/ doc set, both builds - not public

Performance and behaviour changes ship one at a time

practices.one-change-per-deploy - recommended - developer - proved by the build

Deploy one change, verify it, then start the next. Never widen a change while debugging it. Two changes in one deploy means neither result is attributable.

Without it, eight changes ship in one commit, the score drops, and there is no way to tell which one did it.

Accepted when. Each performance claim in the handover names the single change it came from and the before and after measurement.

Source. PERFORMANCE.md, wp-starter, our own repository (house) - PERFORMANCE.md:89-92, in part - not public

Production is indexable and staging is not

seo.no-noindex-leak - blocker - developer - proved by the build - driven by environment, never a checkbox

Drive the noindex decision from the environment, never from a checkbox someone has to remember. Staging returns noindex on every response; production returns none.

Without it, a database pull from staging carries blog_public=0 into production and the site goes dark after a deploy that reported success.

Accepted when. The production home page carries no noindex in the meta robots or the X-Robots-Tag header, and its robots.txt does not disallow the whole site. Staging carries noindex everywhere.

Source. Google Search Central documentation, Google (vendor) - crawling-indexing/block-indexing, in part - https://developers.google.com/search/docs/crawling-indexing/block-indexing

HTTPS is enforced and nothing loads over http

seo.https-and-mixed-content - blocker - developer - proved by the build - one 301 to https, no http:// asset

Redirect http to https in one hop and make sure no stylesheet, script, image, font or iframe has a hard-coded http:// URL. These usually arrive with migrated content, not code.

Without it, a migrated page pulls one script over http, the browser blocks it outright, and that block only shows up on the client's live domain.

Accepted when. http:// returns a single 301 to https://, and no sampled page contains an http:// asset URL.

Source. web.dev developer guides, Google (web.dev) (vendor) - articles/what-is-mixed-content, in part - https://web.dev/articles/what-is-mixed-content

Replaced URLs redirect to their new home

seo.redirects-on-launch - blocker - developer - proved by the build - one 301 to a 200, never bulk to the home page

Only when the project replaces an existing live site.

Export the old site's indexed URLs before launch and map each one to its replacement with a 301. Anything with no equivalent goes to the nearest parent, not to the home page in bulk.

Without it, every ranking and inbound link the old site earned dies at cutover, and by the time anyone notices the list of old URLs is gone too.

Accepted when. Every URL on the old-site list returns 200 or a single 301 to a 200. No mass redirect to the home page. Spot-check the twenty highest-traffic URLs by hand.

Source. Google Search Central documentation, Google (vendor) - crawling-indexing/site-move-with-url-changes, in part - https://developers.google.com/search/docs/crawling-indexing/site-move-with-url-changes

Only the production host serves an indexable copy of the site

crawl.sibling-hosts - blocker - developer - proved by the build - every other host gated or noindex

Enumerate every hostname that resolves to this project - admin, dev, staging, preview, ETL, the agency's own subdomain, and whatever the platform hands out by default - and make every one of them that is not production either refuse the request or answer noindex on every response. Ask the client for the list rather than assuming the brief is complete, and write the list down.

Without it, Google can pick the staging copy as the canonical one, and the production URLs quietly stop being the ones that rank.

Accepted when. Every enumerated host except production returns 401, 403, or a noindex on both the home page and one deep page. No non-production host returns an indexable 200. The enumerated list ships with the handover, dated.

Source. Technical SEO control grid, A third-party SEO agency, commissioned by Colibrity (client) - row 6 - not public

Every inventoried URL reaches its replacement in one hop

migration.redirect-map - blocker - developer - proved by the build - 200 or a single 301, never bulk to home

Only when the project replaces an existing live site.

Map every URL on the inventory to its closest equivalent with a 301. No equivalent means the nearest parent, and genuinely gone means 410. Never redirect in bulk to the home page. Build the map as a file that ships with the project rather than as rules somebody typed into a control panel.

Without it, every old deep link lands on the home page, Google reads the lot as soft 404s, and the rankings the old site had are gone.

Accepted when. Every URL on the inventory returns 200, or exactly one 301 to a 200. Zero chains of two or more hops, measured with the http and www variants included. No more than 5 per cent of the inventory lands on the home page, and each one that does has a reason recorded.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 8, item 24, in part - not public

The client's admin is trimmed, and the trim can always be undone

admin.client-mode - required - developer - proved by the build - declared in code, reversible, reaches the real accounts

Take developer clutter out of the admin the client uses, and declare what goes as a list in code, not as a description. A site-wide switch and per-user self-service both qualify. What does not is a trim that exempts the role the client's own people hold: that is a no-op dressed as a feature. Whoever it applies to has to be able to undo it, so the control that undoes it is never itself hidden.

Without it, the client's own people are administrators, and a trim that exempts administrators leaves the only accounts it exists to help untouched.

Accepted when. What is hidden is a list in the repository. An account the client actually uses sees the trimmed admin and can still reach the control that undoes it. No account can be locked out of its own reversal.

Source. Delivered reference builds, audited together, Our own delivered work (house) - inc/admin-cleanup.php, both builds - not public

What the client must not do is refused, not hidden

admin.capability-boundaries - required - developer - proved by the build - a denied capability, verified by opening the URL

Decide for each thing the client must not do whether it is clutter or a boundary, because the two are fixed by different mechanisms. Clutter is hidden. A boundary is a capability: deny it where the post type is registered and map it, so the action is refused wherever it is attempted rather than merely absent from the screen it was attempted from. CSS is not how you get one.

Without it, a hidden menu reads as a locked one, and the screen still answers at its own URL to anyone who types it.

Accepted when. For every screen or action the client must not reach, a request made as the client's own account is refused by WordPress, checked at the URL and not in the sidebar. Where hiding is used instead, the repository says in words that it is cosmetic.

Source. Delivered reference builds, audited together, Our own delivered work (house) - create_posts => do_not_allow with map_meta_cap, both builds - not public

One control writes one setting

admin.one-control-per-setting - required - developer - proved by the build - no two boxes claiming the same value

When a plugin takes over something the theme also offers, stop rendering the theme's version while that plugin is active instead of leaving both on screen. Two boxes claiming to set the same value teach the client that the site is unpredictable, and the one they fill in will be the one you did not wire up. One settings page for the site's own options, not one per feature.

Without it, the client sets the value in the plugin, the theme's box overwrites it on the next save, and nothing says which one won.

Accepted when. For each setting the client can change, exactly one control in the admin writes it. Where a plugin supersedes a theme feature, the theme's control is not rendered while that plugin is active, checked with the plugin on and off.

Source. Delivered reference builds, audited together, Our own delivered work (house) - theme SEO panel hidden while Yoast is active, both builds - not public

The client leaves handover able to run the site

delivery.client-handover - required - developer - proved by the agency - a dated recording, plus a credential inventory by holder

Record a session with the people who will run the site: editing content, adding a page, what must not be touched. Then write the credential inventory - every account the site depends on, who holds it, and whether it transferred at handover or Colibrity retains it and why. It is what settles who owns the DNS zone, the analytics property, the CRM and the hosting.

Without it, the client cannot add a page without ringing us, and nobody can say who holds the DNS zone or the mail relay when it breaks.

Accepted when. The client has a dated recording of the session covering editing content, adding a page and what must not be touched, naming at least 2 attendees. The wider account table is not graded anywhere since ops.account-inventory was withdrawn - raise it, do not check it.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 15, item 7, in part - not public

Uptime monitoring and a restorable backup are live at launch

delivery.monitoring-and-backups - required - ops - proved by the client - checks at 5 min or better, one dated restore performed

Put an uptime check on the home page at five minutes or better, alerting a named person. Then take a backup and actually restore it somewhere before handover - a backup nobody has restored is a hope with a filename. Where Colibrity resells the hosting this is agency-owned. If nobody will own it at handover, write that down rather than ticking it.

Without it, the site is down all weekend before anyone notices, because the client is the only monitor and nobody was named to be told.

Accepted when. The monitor's public status URL shows checks on the home page at five minutes or better, and the handover names the person it alerts and the date they took it on. If nobody will own it, the handover records that and it is not ticked.

Source. Website Design and Development Checklist, Colibrity (Mariya, CEO) (client) - section 15, item 6, in part - not public

The admin has been tested by someone who did not build it

delivery.editor-test - recommended - developer - proved by the build - a dated sheet, a tester off the build

Before the handover session, sit someone who did not build the site at the admin with a task script: add a page, add a block, change a translation, find a submission. Note each point where they needed help. Fix what can be fixed and write the rest into the handover notes, so the client's first session is not the first test.

Without it, the client is the first person to discover the block picker is a mystery, on a call, after the invoice.

Accepted when. The handover carries a dated observation sheet naming the tester, the tasks attempted and each point of friction with what was done about it. The tester is not a developer on the build.

Ships in wp-starter/editor-test-kit.

Source. Delivered reference builds, audited together, Our own delivered work (house) - docs/editor-test in the third build; nothing of the kind in the two others - not public

What production is running is a commit you can name

env.deploy-provenance - required - ops - proved by the build - the live build id resolves to a named commit

Deploy from a committed tree, and expose the commit where the live site can be read: a stamped file, a response header, or the built asset path. Never deploy from a dirty working tree. Without this a hotfix edited on the server exists nowhere in git, nobody can reproduce the running site, and a rollback restores something that was never live.

Without it, nobody can tell whether the bug they are chasing lives in the repository or in a file somebody edited on the server months ago.

Accepted when. The live site exposes an identifier that resolves to one commit on the default branch of the repository handed over. A deployment naming no commit anywhere in its response or in a stamped file does not pass.

Source. The Twelve-Factor App, Salesforce (Heroku), written by Adam Wiggins (vendor) - V. Build, release, run, in part - https://12factor.net/build-release-run

The administrator list is named and owned before handover

security.admin-accounts-at-handover - required - ops - proved by the client - admin list named, build access removed

At handover, list every administrator with its email address, remove or demote the build's own accounts, and delete anything generic. Ask the client to confirm in writing that each remaining account is theirs, and keep that reply. Old agency logins are how a site gets changed two years later by someone nobody can reach, and they show up in no automated check.

Without it, the developer's own login is still an administrator on the live site a year later, and nobody has confirmed who the rest belong to.

Accepted when. wp user list --role=administrator names no account called admin and no build account above editor, and a dated written confirmation from the client - an email reply or a countersigned handover line - claims every remaining administrator.

Source. Hardening WordPress, WordPress (vendor) - security/brute-force#hardening, in part - https://developer.wordpress.org/advanced-administration/security/brute-force/#hardening

Core, plugins, themes and PHP are current, and updates have an owner

security.versions-current - required - ops - proved by the build - 0 pending, PHP supported, owner named

Take the site to zero pending core, plugin and theme updates before launch, and check PHP is on a branch php.net's supported-versions table still lists as receiving security fixes. Name in the handover who applies updates for the next twelve months, or record in writing that nobody does. Drop any plugin that has gone two years without a release - an abandoned plugin is an unpatchable one.

Without it, a published plugin CVE stays open for months on a site where nobody ever agreed to be the person applying updates.

Accepted when. wp core check-update and wp plugin list --update=available --format=count both return zero on handover day, PHP is on a branch php.net still lists under security support, and the handover names the party applying updates for twelve months.

Source. Hardening WordPress, WordPress (vendor) - security/hardening#vulnerabilities-in-wordpress, in part - https://developer.wordpress.org/advanced-administration/security/hardening/#vulnerabilities-in-wordpress