The site standard — one shell, two brands

clanker.net (this repo, site/) and clanker.engineer (clankerengineer, site/) are two products with two landing pages that must read as the same family. This page is the contract that makes that true: the section order, the nav, the theme script, the meta/OG contract, the route-manifest shape and the post-build check that every page is present. It was written from clankerengineer’s site/src/html/home.html as it stood when this site was scaffolded (plan T121); T120 extracted that shell into clankerengineer’s site/src/lib/site-shell.ts, this site carries its own copy of that file, and tests/site-shell-parity.test.ts diffs the two at a pinned clankerengineer SHA (tests/fixtures/site-shell.ce.sha) — see “The shell” below.

A rule below is a rule because breaking it produced a page that LOOKED right — the failure mode a visual review does not catch. Where the reason is a specific incident it is named.

The pipeline (the same shape in both repos, not the same paths)

site/
  astro.config.mjs          output: static, format: directory, remark plugins
  _headers                  Cloudflare Pages headers — source of truth, copied to public/
  remark-base-url.mjs       the base-URL token → the build's base URL, in docs markdown
  src/html/*.html           hand-authored pages: head + style + body in ONE file
  src/lib/site-shell.ts     the nav, footer, theme bootstrap and chrome tag (see "The shell")
  src/lib/render-html-page.ts   substitutes placeholders, splits into { head, body }
  src/pages/*.astro         one wrapper per HTML page: <head set:html> <body set:html>
  src/pages/docs/           the docs content collection, one real URL per markdown file
  src/layouts/DocsLayout.astro
  public/assets/site-chrome.js  theme toggle + mobile nav (shared, byte-identical)
  scripts/check-static-site.mjs the post-build gate
  scripts/static-site-env.mjs   the build's env contract (required, no fallback)

That is THIS repo’s layout, where site/ is a standalone package. In clankerengineer site/ has no package.json (astro lives in its root), so the build plumbing sits at the ROOT instead: scripts/check-static-site.mjs, scripts/static-site-env.mjs, and site-chrome.js’s SOURCE is assets/site-chrome.js, copied into site/public/assets/ at build (T120 erratum — an earlier revision of this page said it lived under site/public on both sides). src/lib/site-shell.ts and src/lib/render-html-page.ts are under site/src/lib/ in both.

A hand-authored page is a complete HTML document. The Astro wrapper reads it, substitutes placeholders, and emits <head> and <body> verbatim — Astro is the static emitter and nothing else. That is deliberate: the pages carry their own <style> so that a page can be opened from disk and reviewed as a file, and so that no shared stylesheet can change one page while someone is looking at another.

Placeholders are <NAME>_PLACEHOLDER tokens. (This page spells them as NAME + suffix on purpose: the docs pipeline substitutes the base-URL token in every markdown file, this one included, and the post-build check refuses any artifact still carrying a _PLACEHOLDER literal — the page describing the tokens is not exempt from either.) render-html-page.ts fails the build on ANY surviving token, and check-static-site.mjs scans the artifact for the same pattern, so a token that neither side knows is a red build, never a shipped literal. This site has two:

NAMEValueEnv
BASE_URLthis deployment’s origin, no trailing /STATIC_SITE_BASE_URL (then APP_URL)
APP_ORIGINwhere “Open the app” goesSTATIC_SITE_APP_ORIGIN

clankerengineer additionally has SKILL_COUNT (its marketplace size); this site has none of the marketplace and must not grow one.

The SHELL tokens are the same five in both repos, rendered by site-shell.ts rather than read from env: THEME_BOOTSTRAP (in <head>, before <style>), SITE_CHROME (the deferred script tag), SITE_NAV and SITE_FOOTER (the page’s Astro wrapper names a preset — renderHtmlPage("deployed-mode.html", { nav: "subpage", footer: "subpage" })), and THEME_TOGGLE (the standalone button for the legal pages, which have no nav). The shell is substituted FIRST and the site values SECOND, because the “Open the app” preset link is the literal APP_ORIGIN token: the app origin is build-time env the shell must not read.

Every value is required with NO domain fallback (scripts/static-site-env.mjs). A default of https://clanker.net would make a preview build advertise the production canonical in every og:url, and crawlers would believe it.

The shell — src/lib/site-shell.ts

One file, two halves, and the parity test treats them oppositely:

  • The render halfTHEME_STORAGE_KEY, themeBootstrapScript(), siteChromeScriptTag(), renderThemeToggle(), renderNav(), renderFooter() and their types — is BYTE-IDENTICAL to clankerengineer’s at the pinned SHA. It owns markup and class names only; the CSS stays in each page (above). Fix a bug in it on BOTH sides, in the same PR pair, then move the pin.
  • The brand halfBRAND (CLANKERNET here, clanker.engineer there), PUBLISHER (shared), the cross-site link (PRODUCT_SITEhttps://clanker.engineer here; OWN_CLOUD_SITEhttps://clanker.net there), NAV, FOOTER, FOOTER_BRAND — is asserted DIFFERENT, so a lazy full copy of the other repo’s file fails the test instead of shipping the other product’s name in this nav.

The file has no imports, so it is droppable into either build. The header comment (everything before the first export) says which repo you are in and is the one comment that legitimately differs; the parity slice starts at the first export and ends at the // Brand data banner.

tests/site-shell-parity.test.ts is OFFLINE (clankerengineer is private): it diffs against tests/fixtures/site-shell.ce.ts, a git show capture pinned by tests/fixtures/site-shell.ce.sha (the clankerengineer commit) and a sha256 constant in the test (the human acknowledgement). To move the pin:

UPDATE_FIXTURE=1 CE_REPO=/path/to/clankerengineer [CE_SHA=<commit>] \
  npx jest tests/site-shell-parity

then paste the printed sha256 into the test and port the render-half change.

A limit of the render half as it stands at the pin: renderFooter ignores NavLink.attrs, so a FOOTER link cannot carry data-app-cta — only the nav’s “Open the app” does, and that is the link check-static-site.mjs finds. If a footer link ever needs an attribute, add it to renderExternal on the clankerengineer side first.

Section order

The body of the landing page, top to bottom. Class names are the shell’s; the copy inside is the product’s.

#SectionElement / classclanker.netclanker.engineer
0Navnav.nav > .nav-innerbrand, anchors, Deployed mode, Docs, Open the app (highlight)brand, Skills, Pricing, Get Started
1Herosection.heroascii glyph, h1, tagline, description, 2 CTAs, “Built on” pillssame shape, “Works with” pills
2How it workssection.how-it-works > ol.how-steps3 steps: commit factory.yml → run the workflow → ssh4 steps, then External Agents (Hermes & OpenClaw pairing)
3Codesection.code-section > .code-blockfactory.yml in twenty lines (one slide)rotating CLI/REST/MCP samples — placed AFTER the product table, just before Plans (USER 2026-09-17: too technical to lead)
4Product tablesection.section (id byoc)BYOC: what you bring / what we holdstats + capabilities
5Modes / pricingsection.section#pricing > .pricing-gridOperated (now) · Self-serve (when public)BYOK · PAYG · BASED
6CTAsection.get-started-section > .get-started-cardOpen the app · Deployed modeskill.md link · Get the app
7Footerfooter.footer > .footer-inner > .footer-gridbrand col + Product / Docs / Family; legal rowbrand col + Product / Modes / Resources / Company

Section 5 ends with the ONE cross-link to the sister product: “Building with Clanker? The hosted product is at clanker.engineer” (.pricing-family-note). It is a pointer to a DIFFERENT product, never “the product we host for you”: what a factory deploys is the factory — environments for the adopter’s own product — and clankerengineer is never installed in a customer’s cloud (plan D25). clankerengineer’s landing carries no deployment-shaped cross-sell in return (D25 deleted its “Need it in your own cloud?” note; a sister-product footer link is all it keeps). That line are the only places this site names clanker.engineer, and never as itself. clanker.engineer’s footer carries NO family link back (USER 2026-09-17).

A subpage (/deployed-mode) uses the SAME nav and footer markup and the section grammar section.section > h2.section-title + p.section-subtitle, with .feature-block, .detail-grid, .deploy-layer, .comparison-table and .notice-box as its building blocks. The legal pages (/privacy, /terms) are a single column with a fixed-position theme toggle and no nav; they share the theme script and site-chrome.js only.

  • Brand link at the left: the product name in the display face (Tiny5), href="/".
  • .nav-links#navLinks: anchors to on-page sections first, then subpages, then the ONE highlighted action (.nav-link.highlight). On this site that action is “Open the app” and carries data-app-cta (see the check).
  • .theme-toggle[data-theme-toggle] then .nav-hamburger[data-nav-toggle] with aria-controls="navLinks". site-chrome.js owns both; the markup contract is documented at the top of that file.
  • Below 640px the links collapse behind the hamburger; .nav-open on the .nav shows them. Escape closes; a tap on a link closes.

Theme

Every page — HTML pages in <head> via the THEME_BOOTSTRAP token, the docs layout at the top of its markup via themeBootstrapScript() — carries this inline, synchronous bootstrap (the shell’s, so the two cannot drift) BEFORE any styled element paints:

(function () {
  try {
    var t = localStorage.getItem("clanker-theme");
    if (t !== "light" && t !== "dark") {
      t =
        window.matchMedia &&
        window.matchMedia("(prefers-color-scheme: light)").matches
          ? "light"
          : "dark";
    }
    document.documentElement.setAttribute("data-theme", t);
  } catch (e) {
    document.documentElement.setAttribute("data-theme", "dark");
  }
})();

Rules, each with its reason:

  • The storage key is clanker-theme on both sites. They are separate origins, so the value does not cross — but the SCRIPT is copied between them, and two keys would mean the copy silently stops honouring the choice made on the other page of the same site.
  • Stored choice → system preference → dark. Dark is the fallback because the palette is dark-first; a light flash on a dark page is the visible failure.
  • Inline and synchronous, never deferred. Deferring it flashes the wrong theme on every navigation. These are real page loads.
  • Styles key off :root[data-theme="light"] overriding dark defaults in :root. Never a media query alone — the toggle must win.
  • site-chrome.js is loaded defer and only wires the toggle, the hamburger and meta[name=theme-color]; it never sets the initial theme.
  • <meta name="color-scheme" content="light dark"> sits in every head so form controls and scrollbars follow.

check-static-site.mjs refuses any HTML page without the bootstrap.

Meta / OG contract

Every page head carries, after <title> and <meta charset>: the theme script, color-scheme, viewport, description, robots, theme-color, Open Graph, Twitter, canonical, icons, fonts, JSON-LD (landing and mode pages only), then <style>. One position is load-bearing, and it is the one check-static-site.mjs looks for: the theme script before any styled element (Theme, above). The rest is the order the pages happen to use; neither site’s pages agree on it exactly (the legal pages put color-scheme/viewport before <title>), so do not write a check that pins it.

TagRule
<title>PRODUCT — tagline on the landing; Page | PRODUCT on subpages; Page - PRODUCT on legal. PRODUCT is CLANKERNET here and clankerengineer’s own name there — never the other one.
og:url, canonicalthe base-URL token + the page path, no trailing slash except the docs index. Never a hardcoded host.
og:image, twitter:imagethe base-URL token + /assets/icon.png — ABSOLUTE. The copied mode page had a relative og:image, which every scraper ignores.
og:image:width/heightpresent on the landing (1024×1024); og:image:type image/png.
twitter:cardsummary (the icon is square; summary_large_image would crop it).
robotsindex, follow on every public page.
theme-color#050505 (landing) / #0A0A0A (subpages); site-chrome.js flips it to #ffffff in light.
rel=icon, apple-touch-icon/assets/icon.png, relative — same origin, and the absolute form would bake the base URL into a per-environment favicon for no gain.

The BRAND rule is checked: check-static-site.mjs fails if any page’s <title>, canonical or og:url names the other product’s domain, and if any canonical is not under the manifest’s baseUrl. The two sites share a shell by copy, and a copied <title> is precisely what survives a rebrand.

static-route-manifest.json

Written into public/ by the prepare step, served with Cache-Control: no-store, read by the check script and by any smoke test that wants to know what the artifact claims. The SHARED keys:

{
  "generatedAt": "<ISO timestamp>",
  "environment": "production | preview",
  "baseUrl": "https://…",
  "routes": ["/", "/deployed-mode", "/docs/", "/privacy", "/terms"],
  "dynamicRoutes": [],
  "immutablePrefixes": ["/assets/*", "/_astro/*"]
}
  • environment is one of the two deploy.yml builds: production (a push to main, built FOR https://clanker.net) or preview (a pull request, built for its Pages branch alias). There is no staging — one trunk, no next. hostname, no pre-production project (plan D22). clankerengineer’s set is its own.
  • routes are the page URLs, / first; the docs index in trailing-slash form (it is a directory of pages). Every route MUST be in the check script’s required-files list and vice versa — the check enforces both directions, because the manifest is written before the build and the required list is read after it, and two lists in two files drift.
  • dynamicRoutes is what a worker proxies to an application origin. clankerengineer’s carries /api/*, /auth/*, …, and a dynamicOrigin; this site has NO application on its origin (the app is app.clanker.net), so the list is empty and there is no worker.

Product-specific keys sit beside the shared ones: this site adds product: "clankernet" (so a check cannot pass over the wrong artifact) and appOrigin (the one off-site link every page carries); clankerengineer adds dynamicOrigin and expoManifestRoutes.

check-static-site.mjs — the required-files pattern

The post-build gate, run over dist/ after every build, in CI and by npm run verify. The pattern:

  1. Required files, by name. Every route’s index.html, the manifest, _headers, robots.txt, assets/site-chrome.js, assets/icon.png. Cloudflare Pages answers a missing page by falling through to the site shell — HTTP 200, text/html — so a page that failed to render is indistinguishable from one that works by status code. This list is the only thing that notices.
  2. No unresolved placeholder in any text file of the artifact.
  3. Manifest shape: known environment, absolute baseUrl, / in routes, and the two-way cross-check against (1).
  4. _headers checked where it takes effect: a trailing-splat immutable rule for /assets/*, NO middle wildcard (Pages ignores /*/x/* silently — clankerengineer served every Expo bundle max-age=300 for months while a check verified a manifest key instead of the file), and a CSP present (report-only is the floor).
  5. Brand and canonical per the meta contract above.
  6. The theme bootstrap in every HTML page.
  7. Product claims that must not regress: here, every [data-app-cta] link resolves to appOrigin, and /deployed-mode states the D1 split (“operated by us” now, self-serve “when the product is public”) and links /docs/byoc. Pin a phrase, not a paragraph — the prose may be reworded, the claim may not silently disappear.

clankerengineer’s copy additionally parses and inspects its generated _worker.js and guards Expo assets against the node_modules upload ignore. Those checks are NOT carried here: there is no worker and no app in this artifact, and a check over a file that does not exist passes for the wrong reason.

What this site deliberately does not have

No /skills, /mobile, marketplace, dollarinos, or mode pages other than /deployed-mode (plan T121; the route keeps the name it had on clankerengineer, where the page originated). The product has one tier page because deployed mode is the FACTORY’s BYOC tier — operated by us on the adopter’s infrastructure now, self-serve from their own factory.yml when public (plan D1/D25) — and that is the whole commercial surface; a second pricing surface would be a claim docs/byoc.md has to keep honest for no reader. The page never describes Clanker (skills, agents, marketplace, credits) as the thing being deployed: clankerengineer is a hosted sister product and is never installed in a customer’s cloud.

Building

cd site
npm ci
STATIC_SITE_ENV=preview \
STATIC_SITE_BASE_URL=https://clankernet-public.pages.dev \
STATIC_SITE_APP_ORIGIN=https://app.clanker.net \
npm run verify          # prepare → astro build → check-static-site

site/ has its own package.json and lockfile and is not a root workspace: the root npm ci is for jest, and Astro’s dependency tree has no business in it. The docs collection reads ../docs/*.md in place (operator/** excluded) and tolerates an empty directory — /docs renders a stub until docs/README.md exists.