# Working in this repo

What the site is, how to run it, and how it deploys: [README.md](README.md). This file carries only
the rules, invariants and traps.

## Ground rules

- **Keep it buildless.** Edit `.html`, `.css`, `.js` directly. Do not introduce a bundler, a
  `package.json` or a CSS preprocessor without an explicit decision — the whole deployment model
  depends on the tree being servable as-is.
- **`main` is production.** Verify before you land, not after.
- **Verify by looking.** There is nothing to run: serve the tree, load the page you touched, and
  check it in the browser.
- **Never commit a secret.** `api/_lib/contact-mail.js` and `api/canary.js` read their keys
  (`RESEND_API_KEY`, `TURNSTILE_SECRET_KEY`, `CRON_SECRET`) from `process.env` and must keep doing so.
- **`api/canary.js` must keep refusing requests without the `CRON_SECRET` bearer header**, otherwise
  anyone can make the site send mail. Files under `api/_lib/` are not routes; keep the underscore.

## Invariants

### The frame boundary

The site is built around a central framed zone: `.frame` / `.frame-inner`, `max-width: var(--max-w)`
(1440px), `padding: 0 var(--frame-pad)`. **Every element carrying content — text, logos, nav items,
buttons, cards, headers — stays inside that zone.** The only things allowed to extend past it are
the hero's navy background and its decorative canvas, glows and constellation dots.

When you add or move an element, check it ends up inside `.frame-inner`, or wrapped with
`max-width: var(--max-w); margin: 0 auto; padding: 0 var(--frame-pad)`. Full-bleed layers are
acceptable only as decoration behind framed content. Do not breach the frame unless asked to.

### Content images go through `images/low` → `images/high`

`styles.css` and the inline `style` attributes point at the `low` variant; `script.js` upgrades to
`high` at runtime via `data-high` (on `<img>`) and `data-bg-high` (on background-image elements).
Add new content images as that pair. The handful of root-level assets that pages reference directly
(logos, YC badge, favicons) are listed in README.md and are the exception, not the pattern.

`originals/` is excluded from the deploy by `.vercelignore`, so **a page that references
`originals/…` renders a 404 in production while looking fine locally.** Never link to it.

### The mobile header depends on the footer

Below 620px `styles.css` hides every non-button link in `.nav-cta` — About and Careers — because the
header cannot hold four items beside the logo on a phone: the two buttons alone want 268px of the
237px left over. **That is only safe because all four footers carry About and Careers.** Remove them
from a footer and that page has no route to About on mobile at all; there is no burger menu and no
other navigation. Keep the two in step, and if you add a header link, add it to the footer too.

The same media query shrinks `.brand-logo` to 32px, drops `.btn-arrow` inside the header, and cuts
the button padding and font size. Below 360px it also hides the Desirability Study button, leaving
"Let's talk"; the hero still carries both. Those numbers were measured, not guessed — changing a
header label changes where it collides, so re-measure rather than assuming the breakpoint still
holds.

### Anti-spam behaviour that looks like a bug

`api/contact.js` answers `200 {ok:true}` when the honeypot (`hp_ref`) is filled or when `elapsedMs` is under
`MIN_ELAPSED_MS`. That is deliberate: a bot that gets an error retries. Do not "fix" it into a 4xx.

Two consequences when testing by hand:

- **Wait 4 seconds** between page load and submit, or your message is silently dropped. Every outcome (`dropped`, `rejected`, `sent`, `error`) is logged as one JSON line in the Vercel function logs, so a drop is visible there.
- The whole Turnstile contract — `TURNSTILE_ACTION`, `TURNSTILE_SITEKEY`, `ALLOWED_HOSTNAMES` and
  the `turnstileVerdict` function that applies them — lives in `api/_lib/contact-mail.js`, not in
  `api/contact.js`. It is shared so `api/canary.js` can check the same values and the same function
  the form gates on.
- Turnstile reports the hostname the token was solved on, and an unlisted one is rejected. Adding a
  domain (a new preview host, another custom domain) means adding it to `ALLOWED_HOSTNAMES`. Note
  the check is `verify.hostname && !ALLOWED_HOSTNAMES.includes(…)` — a verified response that
  carries no hostname passes through.
- The token is also bound to an action: `data-action` on the widget in `contact.html` must equal
  `TURNSTILE_ACTION`, and `data-sitekey` must equal `TURNSTILE_SITEKEY`, or every submission is
  rejected with `turnstile_action`. **Change the page and the module together** — the canary's
  `contact_page` check fetches the live page every 4 hours and goes red if they disagree. Give any
  second Turnstile surface its own action.
- **The token is minted on submit, not on page load** — `contact.html` renders the widget
  explicitly with `execution: 'execute'` and calls `turnstile.execute()` from the submit handler.
  Rendering it the default way issues a token to every visitor while only submissions that survive
  the honeypot and the 4-second gate ever reach siteverify, and Cloudflare reads that ratio as
  *"Siteverify isn't being called for Huscarl"* — which looks like a broken integration and is not
  one. It also starts the token's 300s expiry before the visitor has typed anything. Do not
  "simplify" it back to implicit rendering (`class="cf-turnstile"`) or to `execution: 'render'`.

## Traps

- **`legal.html` and `hrm.html` do not load `script.js`.** `index.html`, `about.html` and
  `contact.html` all do, so their `data-high` / `data-bg-high` images upgrade normally. `legal.html`
  carries no such attributes; `hrm.html` is self-contained. Do not add the tag to either without a
  reason.
- **`script.js` carries three blocks that now match nothing in the tree** — the `.cards-grid`
  entrance animation, the `#tcor-poly` morph, and the `[data-card="2"]` flood-map handler. They
  fired only on the removed `index_old.html` landing page and are dead code kept for reference.
  Read a `script.js` selector against the page you are actually editing before trusting it. TCOR
  has no markup anywhere on the site.
- **`[data-anim="reserves"]`** on `index.html` has no handler in `script.js`. It is an unused hook,
  not a broken one.
- **Several images exist in three copies** — at the repo root, in `originals/`, and in
  `images/high/` (`alex-musy.webp`, `paulien-jeunesse.webp`, `flood_risk_animation.webp`,
  `maif_flood_risk_legend.webp`). Only the `images/` copy is served to a page; editing the root copy
  changes nothing visible.
- **`icons/` and `LOGOS/` at the root are referenced by no page.** `LOGOS/` held the investor logo
  band removed from the homepage; `icons/` is superseded by `images/{low,high}/icons/`. `vercel.json`
  still sets immutable cache headers for both paths. Along with the unreferenced root images they
  account for about 15 MB of the tree, so expect greps and directory listings to surface copies no
  page uses.
- **`hrm.html` reaches the parent page only through `postMessage`** — the
  `start-hrm-animation` / `reset-hrm-animation` pair. It shares no CSS or JS with the rest of the
  site, and it loads `logo-navy.webp` through a D3 `.attr("href", …)`, which a grep for `src=` or
  `href="` will miss.
- **`old-origin`** is a leftover remote pointing at a Google Drive folder, from before the repo
  moved to GitLab. `origin` is the GitLab remote; ignore `old-origin`.
