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:
| NAME | Value | Env |
|---|---|---|
BASE_URL | this deployment’s origin, no trailing / | STATIC_SITE_BASE_URL (then APP_URL) |
APP_ORIGIN | where “Open the app” goes | STATIC_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 half —
THEME_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 half —
BRAND(CLANKERNEThere,clanker.engineerthere),PUBLISHER(shared), the cross-site link (PRODUCT_SITE→https://clanker.engineerhere;OWN_CLOUD_SITE→https://clanker.netthere),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.
| # | Section | Element / class | clanker.net | clanker.engineer |
|---|---|---|---|---|
| 0 | Nav | nav.nav > .nav-inner | brand, anchors, Deployed mode, Docs, Open the app (highlight) | brand, Skills, Pricing, Get Started |
| 1 | Hero | section.hero | ascii glyph, h1, tagline, description, 2 CTAs, “Built on” pills | same shape, “Works with” pills |
| 2 | How it works | section.how-it-works > ol.how-steps | 3 steps: commit factory.yml → run the workflow → ssh | 4 steps, then External Agents (Hermes & OpenClaw pairing) |
| 3 | Code | section.code-section > .code-block | factory.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) |
| 4 | Product table | section.section (id byoc) | BYOC: what you bring / what we hold | stats + capabilities |
| 5 | Modes / pricing | section.section#pricing > .pricing-grid | Operated (now) · Self-serve (when public) | BYOK · PAYG · BASED |
| 6 | CTA | section.get-started-section > .get-started-card | Open the app · Deployed mode | skill.md link · Get the app |
| 7 | Footer | footer.footer > .footer-inner > .footer-grid | brand col + Product / Docs / Family; legal row | brand 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.
Nav
- 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 carriesdata-app-cta(see the check)..theme-toggle[data-theme-toggle]then.nav-hamburger[data-nav-toggle]witharia-controls="navLinks".site-chrome.jsowns both; the markup contract is documented at the top of that file.- Below 640px the links collapse behind the hamburger;
.nav-openon the.navshows 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-themeon 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.jsis loadeddeferand only wires the toggle, the hamburger andmeta[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.
| Tag | Rule |
|---|---|
<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, canonical | the base-URL token + the page path, no trailing slash except the docs index. Never a hardcoded host. |
og:image, twitter:image | the base-URL token + /assets/icon.png — ABSOLUTE. The copied mode page had a relative og:image, which every scraper ignores. |
og:image:width/height | present on the landing (1024×1024); og:image:type image/png. |
twitter:card | summary (the icon is square; summary_large_image would crop it). |
robots | index, 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/*"]
}
environmentis one of the twodeploy.ymlbuilds:production(a push tomain, built FORhttps://clanker.net) orpreview(a pull request, built for its Pages branch alias). There is nostaging— one trunk, nonext.hostname, no pre-production project (plan D22). clankerengineer’s set is its own.routesare 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.dynamicRoutesis what a worker proxies to an application origin. clankerengineer’s carries/api/*,/auth/*, …, and adynamicOrigin; this site has NO application on its origin (the app isapp.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:
- 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. - No unresolved placeholder in any text file of the artifact.
- Manifest shape: known
environment, absolutebaseUrl,/inroutes, and the two-way cross-check against (1). _headerschecked where it takes effect: a trailing-splat immutable rule for/assets/*, NO middle wildcard (Pages ignores/*/x/*silently — clankerengineer served every Expo bundlemax-age=300for months while a check verified a manifest key instead of the file), and a CSP present (report-only is the floor).- Brand and canonical per the meta contract above.
- The theme bootstrap in every HTML page.
- Product claims that must not regress: here, every
[data-app-cta]link resolves toappOrigin, and/deployed-modestates 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.