# Nordlight AI Marketing

An editorial one-page site for **Nordlight**, an AI marketing practice. The design pairs a bone-paper, type-led layout (Fraunces display serif + Inter Tight) with a single full-bleed dark "workroom" band for the product demo — one bold moment, everything else quiet.

The contact form writes submissions into **Airtable** through a serverless function, so the API token never reaches the browser.

## Run it locally

No build step, no dependencies, no package manager. Just open the file:

```
open index.html          # macOS
start index.html         # Windows
xdg-open index.html      # Linux
```

For an accurate preview (and to exercise the contact form's API endpoint), serve the folder over HTTP:

```bash
python3 -m http.server 8000
# then visit http://localhost:8000
```

Any static server works for the page itself — `npx serve .`, `php -S localhost:8000`, or drag-and-drop the folder onto Netlify/Vercel/GitHub Pages.

The form endpoint (`functions/api/enquiries.js`) is written for **Cloudflare Pages Functions**. The file-based route `/api/enquiries` only runs on Cloudflare Pages; on other hosts the page still renders and the form reports its "email us directly" fallback instead of silently pretending to save.

## File map

| File | Purpose |
| --- | --- |
| `index.html` | All page markup — header, hero, tool strip, method, dark product band, selected work, engagements, contact form, footer. Includes the live status region for submit feedback and a no-JS fallback note. |
| `styles.css` | Compact, token-driven stylesheet (design tokens at the top of `:root`). Sized to stay well under output limits. |
| `script.js` | Vanilla JS: sticky header state, mobile nav (Escape / outside-click / resize), signal-chip counters, scroll reveals, and contact-form validation with loading, success, field-error and network-error states. Submits to `/api/enquiries`. |
| `functions/api/enquiries.js` | Cloudflare Pages Function. Validates and sanitizes the submission, checks a honeypot and optional Turnstile token, then writes a record to Airtable. Supports a read-only `GET` health check. Returns JSON for every outcome. |
| `favicon.svg` | Inline SVG mark matching the wordmark. |

There is no bundler, no transpiler, and no framework. Fonts load from Google Fonts via `<link>` with `preconnect`.

## Airtable setup

### The base and table

Contact submissions land in a **dedicated Airtable base used only by this site** — deliberately not an existing base, so a marketing site's inbound leads can't collide with unrelated data.

The table is named **`Enquiries`** and has these fields:

| Field | Airtable type | Notes |
| --- | --- | --- |
| `Name` | Single line text | Required. 2–120 characters. |
| `Email` | Email | Required. Lowercased before writing. |
| `Message` | Long text | Required. 10–5,000 characters. |
| `Source` | Single line text | Defaults to `nordlight-site`; the client sends the same value. |
| `Company` | Single line text | Optional. Omitted when blank. |
| `Budget` | Single line text | Optional. Omitted when blank. |
| `User agent` | Single line text | Captured server-side from the request headers. |
| `Referrer` | Single line text | Captured server-side from the request headers. |
| `Created` | Created time | Optional; useful for sorting the inbox. |

If your field names differ, either rename the Airtable fields to match or edit the `fields` object in `functions/api/enquiries.js` — the keys there must be the exact Airtable field names. Field names containing spaces (like `User agent`) are supported.

Writes use `typecast: true`, so Airtable will coerce slightly-off text values rather than rejecting the whole submission.

### Finding your base ID

Open the base in Airtable. The URL looks like:

```
https://airtable.com/appAbCdEfGhIjKlMn/tblXyZ…/viw…
```

The segment starting with **`app`** — `appAbCdEfGhIjKlMn` above — is your `AIRTABLE_BASE_ID`. Copy it exactly. Do **not** paste the whole URL, and do **not** use the `tbl…` table ID in this variable.

### The API token

Create a [personal access token](https://airtable.com/create/tokens) scoped to:

- `data.records:write` — to create records.
- `data.records:read` — only if you want the `GET /api/enquiries?probe=1` health check to verify the table.
- Access limited to **this base only**.

Do not use a token with broader scope, and never put it in `index.html`, `script.js`, or any client-visible file.

### Environment variables

Set these in your hosting provider's environment settings (for Cloudflare Pages: *Settings → Environment variables*, then redeploy — Pages only picks up variable changes on a new deployment). The function reads them from `env`, so they stay server-side.

| Variable | Required | Purpose |
| --- | --- | --- |
| `AIRTABLE_TOKEN` | **Yes** | Airtable personal access token with `data.records:write` on the base. Without it the endpoint returns `503` and the page shows the "not configured yet" message. |
| `AIRTABLE_BASE_ID` | **Yes** | The base ID, starting with `app` (e.g. `appAbCdEfGhIjKlMn`). This is the value the API uses — **not** the `airtable.com/app…/tbl…` URL. Without it the endpoint returns `503`. |
| `AIRTABLE_TABLE_NAME` | No | Table name. Defaults to `Enquiries`. Set it only if you renamed the table. |
| `TURNSTILE_SECRET_KEY` | No | Cloudflare Turnstile secret. When set, the function requires a valid `turnstileToken` in the submission; when unset, the human check is skipped (the honeypot still applies). |

**Do not commit real values.** Add them only through the host's secret/environment-variable UI. Locally, Cloudflare Pages lets you use a `.dev.vars` file (which must stay git-ignored) containing the same names for `wrangler pages dev`:

```
AIRTABLE_TOKEN=pat…
AIRTABLE_BASE_ID=app…
AIRTABLE_TABLE_NAME=Enquiries
```

### Checking the wiring

Once deployed, open the health endpoint in a browser:

```
https://your-site.pages.dev/api/enquiries
```

It returns JSON confirming whether the configuration is present, which table it will write to, and whether Turnstile is on:

```json
{
  "ok": true,
  "service": "enquiries",
  "configured": true,
  "table": "Enquiries",
  "turnstile": "disabled",
  "accepts": ["POST"]
}
```

`configured: false` with a `missingConfig` array means `AIRTABLE_TOKEN` and/or `AIRTABLE_BASE_ID` aren't visible to the function — add them and redeploy. This check never writes data.

To also verify the token can actually read the target table (catches a wrong base ID, a wrong table name, or a token without base access), append `?probe=1`:

```
https://your-site.pages.dev/api/enquiries?probe=1
```

On success you get `{ "ok": true, "probe": { "table": "Enquiries", "recordCount": 12 } }`. On failure the response names the Airtable error type and message (for example `NOT_FOUND` / `NOT_AUTHORIZED`), which tells you exactly which part to fix.

### What the endpoint accepts

`POST /api/enquiries` with either `application/json` or `application/x-www-form-urlencoded` / `multipart/form-data`:

```json
{
  "name": "Sam Okafor",
  "email": "sam@example.com",
  "company": "Northwind Studio",
  "budget": "£10k–£25k",
  "message": "We need help with paid search and creative production.",
  "source": "nordlight-site",
  "turnstileToken": "optional-when-turnstile-is-enabled"
}
```

Responses are always JSON with an `ok` flag:

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `{ "ok": true, "id": "rec…" }` | Record created. Honeypot hits also return `200` with `id: null` so bots see success. |
| `400` | `{ "ok": false, "error": "…" }` | Malformed body, or failed human verification. |
| `422` | `{ "ok": false, "error": "…", "fields": { … } }` | Server-side validation failed; `fields` maps input names to messages. |
| `503` | `{ "ok": false, "error": "…" }` | `AIRTABLE_TOKEN` or `AIRTABLE_BASE_ID` is missing. |
| `502` | `{ "ok": false, "error": "…" }` | Airtable rejected the write or timed out (15s budget). The response includes Airtable's error type and message. |

To verify a submission actually landed, open the `Enquiries` table in Airtable and confirm the new row — the endpoint returns the record ID only when Airtable accepted it.

### When a submission still doesn't appear

Work down this list in order; each step isolates one link in the chain.

1. **The endpoint isn't running.** On any host other than Cloudflare Pages, `/api/enquiries` doesn't exist. The page shows the "email us directly" fallback in that case — if you see a success message but no row, you're on Pages but the function errored; continue below.
2. **Configuration missing.** Load `GET /api/enquiries`. If `configured` is `false`, add the missing variable and redeploy.
3. **Wrong resource.** Load `GET /api/enquiries?probe=1`. `NOT_FOUND` means the base ID or table name is wrong; `NOT_AUTHORIZED` means the token lacks access to that base or the `data.records:read` scope.
4. **Wrong field names.** Airtable returns `UNKNOWN_FIELD_NAME` when a mapped field doesn't exist in the table. Rename the field in Airtable or edit the `fields` object in `functions/api/enquiries.js`.
5. **Wrong field types.** `INVALID_VALUE_FOR_COLUMN` means, for example, text in a numeric field. `typecast: true` handles most text mismatches, but not all.
6. **Turnstile mismatch.** If `TURNSTILE_SECRET_KEY` is set but the page has no Turnstile widget, every submission fails verification with `400`. Either add the widget or unset the secret.

The client shows the server's error message in the form's status region, so the first place to look is what the page itself reports after submitting.

## Abuse protection

- **Honeypot** — a hidden `website` field; anything in it is treated as a bot and silently accepted but discarded.
- **Rate limiting** — not implemented in the function. If the form gets targeted, put Cloudflare's rate-limiting rules in front of `/api/enquiries`.
- **Turnstile** — optional, enabled by setting `TURNSTILE_SECRET_KEY`. Add the Turnstile widget to the form and send its token as `turnstileToken` to turn it on.

## Design system

- **Palette** — Midnight Ink `#0E1116`, Bone Paper `#F4F1EA`, Signal Copper `#C9643A`, Cold Signal `#6FB4C4`, Graphite `#2A2F37`.
- **Type** — Fraunces (variable serif) for display headlines; Inter Tight for body, UI and numerals.
- **Structure** — hairline rules and type scale carry the hierarchy; no shadowed card grids. One full-bleed dark band provides the visual peak.
- **Motion** — all reveal and counter animation respects `prefers-reduced-motion: reduce` and degrades to static final states. Nothing depends on motion to be readable or usable.

## Content honesty

Every number, case study, and figure on this site is **illustrative sample content**, written to demonstrate how Nordlight communicates — not a description of any real client engagement. In particular:

- The hero "Live signal readout" chip animates demonstration figures and is explicitly labelled **sample data**, with a note that the values come from a demonstration workspace.
- "Selected work" case studies are labelled as sample work and framed as examples of how we frame a project, not as published results.
- No real client names, logos, testimonials, or performance metrics are claimed.

If you adapt this template for a real agency, replace these figures with verified numbers, remove the sample labels only once the data is real, and re-check every claim with the client before publishing.

## Accessibility notes

- Skip link to `#main`.
- Semantic landmarks (`header`, `nav`, `main`, `section`, `footer`) and heading order.
- Form inputs have persistent `<label>`s, `aria-describedby` hints, and inline error regions announced politely.
- The submit status region uses `role="status"` and `aria-live="polite"`, and the submit button reflects a disabled busy state while the request is in flight.
- Visible `:focus-visible` rings using Signal Copper.
- Mobile nav restores focus to the toggle on close and closes on Escape.
- Without JavaScript the form still posts natively; a note explains that an email address is the reliable route in that case.

## Browser support

Current Chrome, Safari, Firefox and Edge. Uses `color-mix()` (Chrome 111+ / Safari 16.2+ / Firefox 113+) with a solid `background` fallback for the transparent sticky header. The contact form degrades gracefully where `fetch` or the endpoint is unavailable.

## License

Site code and content in this repository are unlicensed sample material. Fonts are loaded from Google Fonts under their respective open licenses (Fraunces and Inter Tight, both SIL Open Font License).