# Lead Form Appearance: Matching a Website's Style

This guide explains how an agent with a full-scope PAT/MCP token makes a GigOrganizer
lead form look like a target website. Everything is controlled through a bounded JSONB
`branding` field — no raw CSS ever touches the database.

---

## Overview

The `branding` field on a `lead_forms` row is a JSONB object whose keys come from a
closed vocabulary. Every value is an enum member, a six-digit hex color, or a
clamped integer. Raw CSS strings are never accepted; the sanitizer drops anything that
is not on the allowlist. This means:

- There is no injection surface. You cannot ship `border-radius: 999px; color: red`.
- Reading the stored value is safe in any context.
- Appearance is a **Pro-only** feature. Non-Pro tokens that include `branding` in a
  PATCH body receive a silent 200 with the `branding` key ignored.

The agent's job is to map a target site's CSS into this bounded vocabulary. The
deterministic converter (`appearance-from-styles.ts`) handles resolved computed styles;
the agent handles CSS-source parsing and judgment calls.

---

## Complete Branding Schema

All fields are optional. Absent fields fall back to form defaults. Set a field to
`null` in a PATCH body to remove it.

| Field | Type / Allowed Values | Meaning |
|---|---|---|
| `fontFamily` | string — curated list or `"__host_font__"` | Form font. `"__host_font__"` inherits from the embedding page's `font-family`. |
| `backgroundColor` | `#rrggbb` hex | Form card/container background. |
| `inputBackgroundColor` | `#rrggbb` hex | Background fill of text inputs, selects, and textareas. Defaults to white. |
| `textColor` | `#rrggbb` hex | Foreground text color inside inputs. |
| `logoEnabled` | `boolean` | When `false`, hides an uploaded logo on the public form. Default `true`. |
| `minimizePoweredBy` | `boolean` | Reduces prominence of the "Powered by GigOrganizer" footer badge (Pro). |
| `cornerStyle` | `"rounded"` \| `"sharp"` \| `"pill"` | Corner radius on input fields. Applied to all inputs; see `buttonCornerStyle` for button override. |
| `inputBorderColor` | `#rrggbb` hex | Border color of input fields. Defaults to the scheme border. |
| `inputBorderWidth` | integer `0`–`4` (px) | Input border thickness. `0` removes the border line (combine with `inputBorderStyle: "none"`). Max is `4` — above that it no longer reads as a form border. |
| `inputBorderStyle` | `"solid"` \| `"dashed"` \| `"dotted"` \| `"none"` | Input border line style. |
| `buttonStyle` | `"filled"` \| `"outline"` | Filled = solid background. Outline = transparent background with border. Defaults to `"filled"`. |
| `buttonColor` | `#rrggbb` hex | Button color. Fill background when `buttonStyle: "filled"`; border and text when `"outline"`. Defaults to the form's accent color. |
| `buttonTextColor` | `#rrggbb` hex | Label color on filled buttons. Defaults to white. Has no effect on outline buttons (outline uses `buttonColor` for text). |
| `buttonCornerStyle` | `"rounded"` \| `"sharp"` \| `"pill"` | Corner radius on the submit button. Defaults to `cornerStyle` when absent. |
| `buttonTextTransform` | `"none"` \| `"uppercase"` \| `"capitalize"` | CSS `text-transform` on the button label. |
| `buttonLetterSpacing` | `"normal"` \| `"wide"` | Button label letter-spacing. `"wide"` ≈ 0.05–0.1em; `"normal"` is browser default. |
| `fieldDensity` | `"compact"` \| `"cozy"` \| `"comfortable"` | Input vertical padding / overall field rhythm. Defaults to `"cozy"` (today's legacy spacing). |
| `labelWeight` | `"normal"` \| `"medium"` \| `"semibold"` \| `"bold"` | `font-weight` on field labels (400 / 500 / 600 / 700). |
| `labelTransform` | `"none"` \| `"uppercase"` | CSS `text-transform` on field labels. |
| `labelColor` | `#rrggbb` hex | Color of field labels. Defaults to `textColor`. |

---

## CSS Property → Appearance Field Mapping

Use this table when you have a site's CSS and need to derive `branding` values manually.

| CSS Property (element) | Appearance Field | Derivation Rule |
|---|---|---|
| `background-color` (container / form) | `backgroundColor` | Parse to `#rrggbb` hex. Skip if transparent or unset. |
| `background-color` (input) | `inputBackgroundColor` | Parse to `#rrggbb` hex. Skip if transparent. |
| `color` (input) | `textColor` | Parse to `#rrggbb` hex. |
| `border-top-color` (input) | `inputBorderColor` | Parse to `#rrggbb` hex. Use `border-top-color` (computed styles decompose shorthand). |
| `border-top-width` (input) | `inputBorderWidth` | Parse px float, round to integer, clamp to 0–4. |
| `border-top-style` (input) | `inputBorderStyle` | Map `"none"` / `"hidden"` → `"none"`. Pass `"solid"` / `"dashed"` / `"dotted"` through. Others → omit. |
| `border-top-left-radius` (input) | `cornerStyle` | `≤ 2 px` → `"sharp"` / `≥ 30 px` → `"pill"` / otherwise → `"rounded"`. |
| `padding-top` + `padding-bottom` (input) | `fieldDensity` | Average the two values: `≤ 7 px` → `"compact"` / `≤ 13 px` → `"cozy"` / `> 13 px` → `"comfortable"`. |
| `background-color` (button) | `buttonColor` + `buttonStyle: "filled"` | Non-transparent background → `"filled"` with that hex. |
| `border-top-color` or `color` (button, no bg) | `buttonColor` + `buttonStyle: "outline"` | No fill but border/text color present → `"outline"`. |
| `color` (button, filled) | `buttonTextColor` | Parse to hex. Only meaningful for `"filled"` buttons. |
| `border-top-left-radius` (button) | `buttonCornerStyle` | Same radius → corner style mapping as inputs above. |
| `text-transform` (button) | `buttonTextTransform` | `"uppercase"` or `"capitalize"` pass through; others → `"none"`. |
| `letter-spacing` (button) | `buttonLetterSpacing` | `≥ 0.5 px` and not `"normal"` → `"wide"`; otherwise → `"normal"`. |
| `color` (label) | `labelColor` | Parse to hex. |
| `font-weight` (label) | `labelWeight` | `≥ 700` or `"bold"` → `"bold"` / `≥ 600` → `"semibold"` / `≥ 500` → `"medium"` / otherwise → `"normal"`. |
| `text-transform` (label) | `labelTransform` | `"uppercase"` → `"uppercase"`; others → `"none"`. |

Note: computed styles always decompose CSS shorthand properties. Read `border-top-color`
not `border-color`, `border-top-width` not `border-width`, `border-top-left-radius` not
`border-radius`. The converter reads the same decomposed properties.

---

## Three Ways to Match a Site

### A. Presets

Three named presets are built from Dan's live guitar-site contact forms (all share the
same Formloom appearance). Apply one by PATCHing `branding` to the preset's spec:

```
POST /api/leads/forms/{form_id}   — (actually PATCH; see below)
PATCH /api/leads/forms/{form_id}
```

Available preset IDs: `artful`, `aspen`, `walter_2`.

**Example — apply the Aspen preset:**

```http
PATCH /api/leads/forms/a1b2c3d4-0000-0000-0000-000000000000
Authorization: Bearer go_pro_YOUR_TOKEN
Content-Type: application/json

{
  "branding": {
    "inputBackgroundColor": "#ffffff",
    "textColor": "#000000",
    "inputBorderColor": "#dbdbdb",
    "inputBorderWidth": 1,
    "inputBorderStyle": "solid",
    "cornerStyle": "rounded",
    "fieldDensity": "cozy",
    "buttonStyle": "filled",
    "buttonColor": "#5670a0",
    "buttonTextColor": "#ffffff",
    "buttonCornerStyle": "rounded",
    "labelColor": "#5670a0",
    "labelWeight": "semibold"
  }
}
```

Response: `{ "form": { ...updated form row... } }`

### B. Validate/Derive Endpoint

`POST /api/leads/forms/appearance/preview` is a stateless, Pro-gated route that
normalizes and validates a candidate `branding` object — or converts a raw computed
style snapshot — and returns the clean, bounded `branding` that GigOrganizer will
actually store. **Nothing is saved.** Use the returned value to inspect what will
land before PATCHing.

**Input mode 1 — pass a computed-style snapshot:**

The agent (or user) runs `COMPUTED_STYLE_SNIPPET` (see below) in the browser console
on a page containing a representative form. The snippet emits a `ComputedStyleSnapshot`
JSON. Post that snapshot to the preview endpoint:

```http
POST /api/leads/forms/appearance/preview
Authorization: Bearer go_pro_YOUR_TOKEN
Content-Type: application/json

{
  "computedStyles": {
    "input": {
      "background-color": "rgb(255, 255, 255)",
      "color": "rgb(0, 0, 0)",
      "border-top-width": "1px",
      "border-top-style": "solid",
      "border-top-color": "rgb(219, 219, 219)",
      "border-top-left-radius": "3px",
      "padding-top": "8px",
      "padding-bottom": "8px",
      "text-transform": "none",
      "letter-spacing": "normal",
      "font-weight": "400",
      "font-family": "Quattrocento, serif"
    },
    "button": {
      "background-color": "rgb(86, 112, 160)",
      "color": "rgb(255, 255, 255)",
      "border-top-width": "0px",
      "border-top-style": "none",
      "border-top-color": "rgb(86, 112, 160)",
      "border-top-left-radius": "3px",
      "text-transform": "none",
      "letter-spacing": "normal",
      "font-weight": "400",
      "font-family": "Quattrocento, serif"
    },
    "label": {
      "color": "rgb(86, 112, 160)",
      "font-weight": "600",
      "text-transform": "none"
    },
    "container": {
      "background-color": "rgb(255, 255, 255)"
    }
  }
}
```

**Response:**
```json
{
  "branding": {
    "backgroundColor": "#ffffff",
    "inputBackgroundColor": "#ffffff",
    "textColor": "#000000",
    "inputBorderColor": "#dbdbdb",
    "inputBorderWidth": 1,
    "inputBorderStyle": "solid",
    "cornerStyle": "rounded",
    "fieldDensity": "cozy",
    "buttonStyle": "filled",
    "buttonColor": "#5670a0",
    "buttonTextColor": "#ffffff",
    "buttonCornerStyle": "rounded",
    "labelColor": "#5670a0",
    "labelWeight": "semibold"
  }
}
```

Only fields that could be confidently derived are present. Fields the converter could
not map are absent (they will fall back to defaults on the live form).

**Input mode 2 — pass a candidate branding object:**

When the agent has mapped CSS manually, POST the candidate directly to validate and
clamp it:

```http
POST /api/leads/forms/appearance/preview
Authorization: Bearer go_pro_YOUR_TOKEN
Content-Type: application/json

{
  "branding": {
    "cornerStyle": "rounded",
    "inputBorderWidth": 7,
    "inputBorderStyle": "solid",
    "inputBorderColor": "#dbdbdb",
    "buttonStyle": "filled",
    "buttonColor": "#5670a0",
    "buttonTextColor": "#ffffff",
    "labelWeight": "semibold",
    "labelColor": "#5670a0",
    "fieldDensity": "cozy"
  }
}
```

The response clamps `inputBorderWidth: 7` to `4` (the max) and strips any keys that
are not on the allowlist. The returned `{ "branding": {...} }` is exactly what PATCH
will store.

**Errors:**
- `400` — body is not an object, or neither `computedStyles` nor `branding` key is present
- `401` — no valid token
- `403` — account is not Pro

### C. Recommended Agent Flow (Reading a Site's CSS)

This is the recommended end-to-end pattern. Raw CSS parsing is intentionally the
agent's job — LLMs handle the cascade, specificity, and variable resolution better
than deterministic code. The converter handles the deterministic resolved→bounded
mapping step.

**Step 1 — Capture computed styles from the target site.**

Run `COMPUTED_STYLE_SNIPPET` in the browser console on a page containing a
representative contact form. The snippet tries common selectors in priority order and
emits resolved values for the 12 CSS properties that map to branding fields.

```javascript
(() => {
  const pick = (el) => {
    if (!el) return undefined;
    const s = getComputedStyle(el);
    const props = ["background-color","color","border-top-width","border-top-style",
      "border-top-color","border-top-left-radius","padding-top","padding-bottom",
      "text-transform","letter-spacing","font-weight","font-family"];
    const o = {}; props.forEach(p => o[p] = s.getPropertyValue(p)); return o;
  };
  const q = (sels) => { for (const sel of sels) { const e = document.querySelector(sel); if (e) return e; } return null; };
  const input = q(['form input[type=text]','form input[type=email]','form textarea','form input:not([type])','input[type=text]','input[type=email]','textarea','input']);
  const button = q(['form button[type=submit]','form input[type=submit]','form button','button[type=submit]','input[type=submit]']);
  const label = q(['form label','label']);
  const container = q(['form']) || document.body;
  const snap = { input: pick(input), button: pick(button), label: pick(label), container: pick(container) };
  const json = JSON.stringify(snap, null, 2);
  console.log(json); try { copy(json); } catch (e) {}
  return json;
})();
```

Copy the JSON from the console.

**Step 2 — POST to the preview endpoint.**

Submit the snapshot to `POST /api/leads/forms/appearance/preview` with
`{ "computedStyles": <paste> }`. The converter returns the bounded `branding` spec.
Inspect it to verify key fields were picked up. If the target site uses unusual form
markup and the selectors missed, map the CSS manually and use the
`{ "branding": <candidate> }` input mode instead.

**Step 3 — PATCH the form.**

Take the `branding` object from the preview response and PATCH it onto the form:

```http
PATCH /api/leads/forms/{form_id}
Authorization: Bearer go_pro_YOUR_TOKEN
Content-Type: application/json

{
  "branding": {
    "backgroundColor": "#ffffff",
    "inputBackgroundColor": "#ffffff",
    "textColor": "#000000",
    "inputBorderColor": "#dbdbdb",
    "inputBorderWidth": 1,
    "inputBorderStyle": "solid",
    "cornerStyle": "rounded",
    "fieldDensity": "cozy",
    "buttonStyle": "filled",
    "buttonColor": "#5670a0",
    "buttonTextColor": "#ffffff",
    "buttonCornerStyle": "rounded",
    "labelColor": "#5670a0",
    "labelWeight": "semibold"
  }
}
```

The PATCH uses the same allowlists as the preview endpoint, so a value that survived
the preview step will also survive the PATCH.

**Step 4 — Verify.**

Read the form back with `GET /api/leads/forms/{form_id}` and inspect `form.branding`.
The public iframe at the form's embed URL will reflect the change immediately (no CDN
cache on branding reads).

---

## Fast path — create + match + logo in ONE call

`POST /api/leads/forms` accepts the appearance inline, so an agent can create a fully
website-matching form in a single request instead of create → preview → PATCH → logo.
New optional JSON fields (Pro; silently ignored for non-Pro callers, who still get a
working default-styled form — a field-level strip, not a 403):

| Field | Meaning |
|---|---|
| `computedStyles` | A `getComputedStyle` snapshot (same shape as the preview endpoint). Converted server-side by the deterministic mapper. |
| `branding` | A candidate bounded `branding` object (when you mapped CSS manually). Sanitized + clamped server-side. |
| `logoUrl` | An `https` image URL. Fetched server-side (SSRF-guarded, ≤500KB, PNG/JPEG), resized, attached as the form logo. **Best-effort:** a logo failure does not fail form creation — the response then includes a `logoError` string so you can retry just the logo. |

Provide `computedStyles` OR `branding` (computedStyles wins if both are present).

```http
POST /api/leads/forms
Authorization: Bearer go_pro_YOUR_TOKEN
Content-Type: application/json

{
  "name": "Booking inquiries",
  "performerNameId": "…",
  "eventTypes": ["Wedding", "Private Party"],
  "computedStyles": { "input": {…}, "button": {…}, "label": {…}, "container": {…} },
  "logoUrl": "https://yoursite.com/assets/logo.png"
}
```

Response: `201 { "form": { …row incl. branding… }, "logoError"?: "…" }`. The public
embed reflects the styling immediately.

## Placing the form on the user's website

GigOrganizer does not host the user's site — an agent places the form using the host's
own connector, with the form's iframe embed. Style capture is platform-agnostic
(WordPress, Squarespace, Wix, hand-rolled HTML all render the same to `getComputedStyle`).

The embed is **deterministic from the slug** returned by create — no extra GigOrganizer
call needed:

```html
<iframe src="https://gigorganizer.com/inquire/{form.slug}?embed=true"
        width="100%" height="600" frameborder="0"
        style="border: none; max-width: 500px;"></iframe>
```

- **WordPress:** create a page/post via the WP REST API (`POST /wp/v2/pages`, or a
  Gutenberg Custom-HTML block) whose content is that iframe. The agent uses its own
  WordPress connector — GigOrganizer is not involved.
- **Generic:** paste the iframe into the site's HTML.

## Bundling a free GigOrganizer public page

Matching the site already gathered the artist's brand + logo, so the same agent session
can also stand up a GigOrganizer public performer page and attach this form to it — a
free, booking-shaped second web presence. Attach the form to the public profile via:

```http
PATCH /api/settings/profile
{ "performerNameId": "…", "leadFormId": "<form_id>" }   // plus slug / bio / photos / profilePublic
```

`performerNameId` goes in the JSON body (the PATCH validates it there via its schema —
a query-string `?performerNameId=` is ignored, and the request 400s without it in the body).

The page lives at `/p/{slug}`. See `docs/plans/agent-booking-presence-setup.md` for the
product rationale (and the honest, non-salesy pitch for why a performer wants one).

The form + public page are two steps of a larger setup flow. When the user wants a full
account setup ("set me up," "get me started"), follow
[agent-onboarding.md](agent-onboarding.md) — it composes this form and public page with
performer identity, contract defaults, and team-from-contacts into one gather-once →
produce-many onboarding.

---

## Design Notes

- **Raw CSS parsing is the agent's responsibility.** The deterministic converter
  (`appearance-from-styles.ts`) only consumes pre-resolved computed style values
  (output of `getComputedStyle()`). Parsing raw CSS source with all its cascade
  ambiguity, CSS variables, and media query conditions is intentionally left to the
  LLM path.
- **`fontFamily` and host-font inheritance.** The `fontFamily` field accepts curated
  font names from `lib/leads/fonts.ts`. Use `"__host_font__"` to tell the embedded
  form to inherit whatever font the host page uses. The converter does not emit
  `fontFamily` (font-family values from computed styles are not bounded to the
  curated list), so set this field manually in the PATCH if needed.
- **PATCH is a merge, not a replace.** Individual `branding` keys are merged into the
  stored JSONB. To remove a key (revert to default), set it to `null` in the PATCH
  body.
- **Preset branding is complete.** A preset sets every key it defines so the result
  is deterministic regardless of the form's prior `branding` state. Manually composed
  `branding` objects may be partial — unset keys keep their current values.
