@typeroll/mcp-server 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -84,6 +84,18 @@ maps to one HTTP endpoint; the actual logic runs in the customer's portal
84
84
  before working with blocks — never hardcode block ids or field names,
85
85
  the available set is per-site.
86
86
 
87
+ Two specifics worth knowing:
88
+ - **`core/html`** is the raw-HTML escape hatch for block-mode pages —
89
+ one `html` field rendered verbatim (then sanitized like HTML-mode
90
+ content). Use it for the genuinely unique thing no block covers: a
91
+ form embed with its signed `_token`, a third-party widget's markup,
92
+ a hand-crafted one-off. Prefer real blocks when one fits.
93
+ - **`script` on custom block types** (create/update_block_type) is
94
+ honoured only when the site has "Allow AI to write block scripts"
95
+ enabled — a human-set portal setting. When it's off your script is
96
+ stripped and the response carries a warning; relay it to the user
97
+ instead of retrying.
98
+
87
99
  - **Redirects.** `from_path → to_path` with status code 301 / 302.
88
100
  Auto-created when you change a page's slug.
89
101
 
@@ -466,7 +478,7 @@ stakeholder review.
466
478
  | **Collections** | `create_collection`, `update_collection_schema`, `delete_collection`, `list_collections`, `read_collection`, `list_collection_items` (richtext hidden by default), `read_collection_item`, `batch_read_collection_items`, `create_collection_item`, `update_collection_item`, `delete_collection_item`, `regenerate_collection_listing` |
467
479
  | **Media** | `list_media`, `read_media`, `create_upload_url`, `upload_media_from_url`, `upload_media_inline`, `update_media`, `delete_media`, `finalize_media`, `finalize_all_media`, `generate_image_variants`, `suggest_alt_text_context` |
468
480
  | **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |
469
- | **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions` |
481
+ | **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions`. read/create return `submit_token` + `submit_url` — embed as a plain `<form method="POST">` with a hidden `_token` input + empty honeypot `_hp`; no client JS (the sanitizer strips inline `<script>`; the endpoint answers form posts with an HTML confirmation page) |
470
482
  | **Settings** | `update_site_settings` (whitelist) |
471
483
  | **Search + bulk** | `search_pages`, `bulk_replace_text` |
472
484
  | **Branches** | `create_branch`, `read_version`, `delete_branch`, `merge_branch` |
@@ -84,15 +84,15 @@ export const blockTypeTools = [
84
84
  }),
85
85
  },
86
86
  // ─── BlockType authoring — CREATE / UPDATE / DELETE ──────────────────
87
- // The chat AI surface intentionally omits the `script` field on these
88
- // three tools. Scripts run with full DOM access in every visitor's
89
- // browser; AI-authored blocks must not carry custom JS. A human can
90
- // add a script later via the portal UI (consent gate) or via the
91
- // cookie-auth API directly. Origin is stamped 'ai' so audit + UI
92
- // distinguish these from human-authored types.
87
+ // `script` runs with full DOM access in every visitor's browser, so
88
+ // agent-authored scripts are gated server-side on a per-site human
89
+ // opt-in (Site.ai_scripts_enabled, set in the portal's settings UI).
90
+ // The tools expose the field; when the site hasn't opted in, the API
91
+ // strips it and returns a warning. Origin is stamped 'ai' so audit +
92
+ // UI distinguish these from human-authored types.
93
93
  {
94
94
  name: 'create_block_type',
95
- description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. **Custom JS is NOT exposed here** if a script is genuinely needed, a human must add it later via the portal UI. Returns the created BlockType.",
95
+ description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. Custom JS via `script` requires the site's \"Allow AI to write block scripts\" setting (human-set in the portal) — when it's off, the field is ignored and the response carries a warning. Returns the created BlockType.",
96
96
  inputSchema: {
97
97
  name: z.string().describe('Machine name (lowercase kebab/underscore, 1-64 chars). Becomes the id.'),
98
98
  label: z.string().optional().describe('Display label (defaults to name).'),
@@ -112,6 +112,7 @@ export const blockTypeTools = [
112
112
  schema: z.array(z.unknown()).optional().describe('FieldDefinition[]. Each field has name, type, label, optional required/default/options/responsive.'),
113
113
  template: z.string().optional().describe('HTML with {{field}}, {{{field}}}, {{=tag}}, {{children}}, {{slot:NAME}} substitutions.'),
114
114
  styles: z.string().optional().describe('Block-scoped CSS. Selectors should start with [data-block="<name>"].'),
115
+ script: z.string().optional().describe('Client-side JS shipped with the block (runs on the published site). Only honoured when the site has enabled AI block scripts; otherwise stripped with a warning in the response.'),
115
116
  version: versionParam,
116
117
  },
117
118
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -126,7 +127,7 @@ export const blockTypeTools = [
126
127
  },
127
128
  {
128
129
  name: 'update_block_type',
129
- description: "Update fields of an existing custom block type. Core blocks (id starts with 'core/') are read-only — they're managed in platform code. Schema/template/styles replace wholesale (no deep merge); other fields shallow-merge. **Custom JS is NOT exposed here** to add or change a script, edit via portal UI.",
130
+ description: "Update fields of an existing custom block type. Core blocks (id starts with 'core/') are read-only — they're managed in platform code. Schema/template/styles replace wholesale (no deep merge); other fields shallow-merge. The `script` field follows the same site-setting gate as create_block_type.",
130
131
  inputSchema: {
131
132
  type_id: z.string().describe('Block type id (custom or third_party). Cannot be a core/* block.'),
132
133
  label: z.string().optional(),
@@ -143,6 +144,7 @@ export const blockTypeTools = [
143
144
  schema: z.array(z.unknown()).optional(),
144
145
  template: z.string().optional(),
145
146
  styles: z.string().optional(),
147
+ script: z.string().optional().describe('Only honoured when the site has enabled AI block scripts; otherwise stripped with a warning.'),
146
148
  version: versionParam,
147
149
  },
148
150
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -1,8 +1,10 @@
1
1
  // Forms — agents can create/update/delete forms, and read submissions.
2
2
  // The customer-facing submit endpoint is HMAC-signed and lives at
3
- // /api/forms/submit; the in-portal chat's `get_form_embed` tool produces
4
- // the snippet customers paste into their pages. This file is the
5
- // management surface.
3
+ // /api/forms/submit. read_form/create_form return `submit_token` +
4
+ // `submit_url`, which is everything an agent needs to embed a working
5
+ // plain-POST form (no client JS — the page sanitizer strips inline
6
+ // <script>, and the endpoint answers form-encoded posts with an HTML
7
+ // confirmation page).
6
8
  import { z } from 'zod';
7
9
  import { ok, withErrorBoundary } from './helpers.js';
8
10
  const fieldSchema = z.object({
@@ -28,7 +30,7 @@ export const formTools = [
28
30
  },
29
31
  {
30
32
  name: 'read_form',
31
- description: 'Read one form by id, including fields, submit_text, and success_message.',
33
+ description: 'Read one form by id, including fields, submit_text, success_message — plus submit_token and submit_url for embedding: a plain <form method="POST" action={submit_url}> with a hidden _token input and an empty honeypot _hp is a fully working no-JS embed.',
32
34
  inputSchema: { form_id: z.string() },
33
35
  handler: withErrorBoundary(async (args, { client, siteId }) => {
34
36
  const res = await client.get(siteId, `forms/${encodeURIComponent(args.form_id)}`);
@@ -37,7 +39,7 @@ export const formTools = [
37
39
  },
38
40
  {
39
41
  name: 'create_form',
40
- description: 'Create a form. Each field has { name, type, label, required?, placeholder?, options? }. Allowed types: text, email, tel, url, number, textarea, select, checkbox, radio, hidden, gdpr_consent. The form can then be embedded on any page use get_form_embed (in-portal chat) or render the form yourself by reading the schema.',
42
+ description: 'Create a form. Each field has { name, type, label, required?, placeholder?, options? }. Allowed types: text, email, tel, url, number, textarea, select, checkbox, radio, hidden, gdpr_consent. The response includes submit_token + submit_url — embed with a plain <form method="POST" action={submit_url}>, a hidden _token input, and an empty honeypot _hp. Do NOT add inline <script> (the page sanitizer strips it); the endpoint answers plain form posts with an HTML confirmation page.',
41
43
  inputSchema: {
42
44
  id: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
43
45
  name: z.string().min(1),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,11 +31,15 @@ create_form {
31
31
  {"name":"subject", "label":"Ämne", "type":"select",
32
32
  "options":["Prisförfrågan","Samarbete","Övrigt"]}
33
33
  ],
34
- "success_message": "Tack! Vi återkommer inom 24 timmar.",
35
- "recipient_email": "hej@acme.se"
34
+ "success_message": "Tack! Vi återkommer inom 24 timmar."
36
35
  }
37
36
  ```
38
37
 
38
+ There is no `recipient_email` field — submissions land in the portal's
39
+ Submissions inbox (`/app/sites/{siteId}/forms/{formId}/submissions`).
40
+ Email notification is a form *action* (not yet wired platform-side);
41
+ tell the customer to check the inbox.
42
+
39
43
  **Field types:** `text`, `email`, `tel`, `url`, `number`, `textarea`,
40
44
  `select`, `radio`, `checkbox`.
41
45
 
@@ -46,22 +50,28 @@ create_form {
46
50
 
47
51
  ### 2. Get the signed embed token
48
52
 
49
- The form's submit button needs a signed token that proves it was
50
- generated by the platform. Fetch it:
53
+ The submit endpoint only accepts requests carrying a platform-signed
54
+ HMAC token. `create_form` returns it directly; you can also fetch it any
55
+ time with:
51
56
 
52
57
  ```
53
58
  read_form form_id="kontakt"
54
59
  ```
55
60
 
56
- The response includes `submit_token` (a short-lived HMAC). Use this in
57
- the HTML.
58
-
59
- Actually, the embed HTML is best generated by reading the form and
60
- building it manually the form's `id` is all you need for the action URL.
61
+ The response includes `submit_token` (put it in the hidden `_token`
62
+ input) and `submit_url` (use it as the form's `action`). The token is
63
+ stable — it only stops working if the platform rotates its signing
64
+ secret so baking it into static page HTML is correct. If
65
+ `submit_token` comes back `null`, the server has no signing secret
66
+ configured (dev setups); the form cannot accept submissions until that's
67
+ fixed — tell the user instead of embedding a broken form.
61
68
 
62
69
  ### 3. Embed the form on a page
63
70
 
64
- Typeroll forms submit to the platform's endpoint. The HTML:
71
+ A plain HTML form POST is the default and needs **no JavaScript**: the
72
+ endpoint answers a normal form-encoded POST with a small confirmation
73
+ page (the form's `success_message` + a link back to the page the
74
+ visitor came from). Validation errors get the same treatment. The HTML:
65
75
 
66
76
  ```html
67
77
  <section class="contact-section">
@@ -70,11 +80,10 @@ Typeroll forms submit to the platform's endpoint. The HTML:
70
80
  <p>Fyll i formuläret så återkommer vi inom 24 timmar.</p>
71
81
 
72
82
  <form class="contact-form"
73
- action="https://app.typeroll.com/api/forms/submit"
83
+ action="SUBMIT_URL"
74
84
  method="POST">
75
- <!-- Hidden fields required by the platform -->
76
- <input type="hidden" name="_form_id" value="kontakt">
77
- <input type="hidden" name="_site_id" value="YOUR_SITE_ID">
85
+ <!-- The signed token is the only hidden field the platform needs;
86
+ it encodes org + site + form identity. -->
78
87
  <input type="hidden" name="_token" value="SIGNED_TOKEN">
79
88
  <!-- Honeypot — must stay empty, bots fill it -->
80
89
  <input type="text" name="_hp" style="display:none" tabindex="-1" autocomplete="off">
@@ -129,32 +138,50 @@ Typeroll forms submit to the platform's endpoint. The HTML:
129
138
  ```
130
139
 
131
140
  **Replace:**
132
- - `YOUR_SITE_ID` → the site's actual id (from `get_site`)
133
- - `SIGNED_TOKEN` → fetch via `read_form form_id="kontakt"` → `submit_token`
141
+ - `SUBMIT_URL` → `submit_url` from `read_form` / `create_form`
142
+ - `SIGNED_TOKEN` → `submit_token` from the same response
143
+
144
+ ### 4. Inline feedback (optional — requires user action)
134
145
 
135
- ### 4. Add success/error handling (optional JS)
146
+ **The page sanitizer strips inline `<script>` from page and partial
147
+ HTML**, so you cannot ship fetch-based submit handling yourself — a
148
+ `<script>` you put in `html_content` is silently removed. The plain
149
+ POST above is the reliable path; use it.
136
150
 
137
- To show inline feedback without a page reload:
151
+ If the customer wants inline feedback without a page reload, the script
152
+ must go into the site's `scripts_body_end` setting, which only a human
153
+ can edit (Settings → Custom code — it's deliberately excluded from the
154
+ AI tool surface). Hand them this snippet for that box; it intercepts the
155
+ form and POSTs the JSON shape `{ token, data }`:
138
156
 
139
157
  ```html
140
158
  <script>
141
159
  (function(){
142
160
  const form = document.querySelector('.contact-form');
143
161
  if(!form) return;
144
- form.addEventListener('submit', async(e) => {
162
+ form.addEventListener('submit', async (e) => {
145
163
  e.preventDefault();
146
164
  const btn = form.querySelector('[type=submit]');
147
165
  btn.disabled = true;
148
166
  btn.textContent = 'Skickar…';
167
+ // The endpoint's fetch contract is JSON: { token, data }.
168
+ const fd = new FormData(form);
169
+ const token = fd.get('_token');
170
+ const data = {};
171
+ fd.forEach((v, k) => { if (k !== '_token') data[k] = v; });
149
172
  try {
150
- const res = await fetch(form.action, {method:'POST', body: new FormData(form)});
151
- const data = await res.json();
152
- if(data.ok) {
153
- form.innerHTML = '<p class="form-success">Tack! Vi återkommer inom 24 timmar.</p>';
173
+ const res = await fetch(form.action, {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({ token, data }),
177
+ });
178
+ const json = await res.json();
179
+ if (res.ok && json.success) {
180
+ form.innerHTML = '<p class="form-success">' + (json.message || 'Tack!') + '</p>';
154
181
  } else {
155
182
  btn.disabled = false;
156
183
  btn.textContent = 'Skicka meddelande';
157
- alert('Något gick fel: ' + (data.error || 'okänt fel'));
184
+ alert('Något gick fel: ' + ((json.errors && json.errors.join(', ')) || json.error || 'okänt fel'));
158
185
  }
159
186
  } catch {
160
187
  btn.disabled = false;
@@ -226,18 +253,19 @@ portal at `/app/sites/{siteId}/forms/kontakt/submissions`.
226
253
 
227
254
  ## Pitfalls
228
255
 
229
- - **Tokens expire.** The `submit_token` in the form embed is short-lived
230
- (24h by default). For forms on long-cached static pages, fetch a fresh
231
- token via `read_form` and rebuild the page HTML, then redeploy.
232
- Alternatively, fetch the token client-side via a small `<script>` that
233
- calls the portal's token endpoint before submit.
234
- - **Field names must be ASCII.** `message` not `meddelande`... wait,
235
- `meddelande` is ASCII. `foretag` not `företag`. Only Swedish/Nordic
236
- special characters (å, ä, ö) cause problems.
256
+ - **Tokens are stable, not expiring.** The `submit_token` stays valid
257
+ until the platform rotates its signing secret (rare, operator-driven).
258
+ Bake it into the static HTML; no refresh logic needed. If submissions
259
+ suddenly 403 after working, re-fetch via `read_form` and republish.
260
+ - **Inline `<script>` does not survive.** The page sanitizer strips it
261
+ from `html_content` never rely on client JS you embed yourself. The
262
+ plain form POST works without it (section 3).
263
+ - **Field names must be lowercase ASCII** (`[a-z][a-z0-9_-]*`):
264
+ `foretag` not `företag`, `amne` not `ämne`. Labels can be anything.
237
265
  - **Don't use the same form_id on two different forms.** IDs must be
238
266
  unique per site — use descriptive names: `kontakt`, `boka`, `nyhetsbrev`.
239
267
  - **Honeypot must be invisible.** `_hp` field must have `display:none`.
240
268
  If it's visible and a real user fills it, their submission is rejected.
241
- - **Recipient email is display-only in phase 1.** The portal stores
242
- submissions; email delivery to `recipient_email` depends on the platform's
243
- email configuration. Confirm with the customer that they'll check the inbox.
269
+ - **No email notifications yet.** Submissions are stored and visible in
270
+ the portal's Submissions inbox; the email action handler isn't wired
271
+ platform-side. Confirm with the customer that they'll check the inbox.
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: tr-imagegen
3
+ description: Use when the user wants to generate images for a Typeroll site with AI models (Gemini, OpenAI, Higgsfield) — hero images, illustrations, section backgrounds, og-images. Triggers on "generera bilder", "generate images", "skapa en hero-bild", "AI-bilder", "bildgenerering", or when a brief calls for imagery that doesn't exist in assets/. Covers the local lab loop (generate → review → pick) and uploading winners to the Typeroll media library.
4
+ ---
5
+
6
+ # Generate images for a Typeroll site (local lab → media library)
7
+
8
+ Image generation runs **locally** in the site workdir — provider API keys
9
+ live in the folder's `.env`, candidates land in `images/lab/`, and only
10
+ the picked winners are uploaded to the Typeroll media library via the
11
+ regular media tools (see `tr-images` for the upload/variants half).
12
+
13
+ Why local: you can look at the candidates (Read the files), iterate on
14
+ prompts cheaply, and never ship a key or a reject anywhere.
15
+
16
+ ## Folder convention
17
+
18
+ ```
19
+ <site>/
20
+ ├── .env # provider keys — GITIGNORED, never committed
21
+ ├── prompts/
22
+ │ └── image-style.md # the site's image style profile (see below)
23
+ └── images/
24
+ └── lab/ # generated candidates — gitignored, disposable
25
+ ```
26
+
27
+ `.env` keys (only the ones the user has — check before assuming):
28
+
29
+ ```
30
+ GEMINI_API_KEY=...
31
+ OPENAI_API_KEY=...
32
+ ```
33
+
34
+ (Higgsfield needs no key here — it connects as an MCP server, see below.)
35
+
36
+ Load them per-command (`source .env` doesn't persist between Bash calls):
37
+
38
+ ```bash
39
+ export $(grep -v '^#' .env | xargs) # prepend to each generation command
40
+ ```
41
+
42
+ ## The style profile — prompts/image-style.md
43
+
44
+ Every site gets ONE style profile that you **prepend to every image
45
+ prompt**. This is what keeps 20 images generated across 5 sessions
46
+ looking like one site. Derive it from `assets/brand.md` + the brief if
47
+ it doesn't exist yet, and confirm it with the user before generating at
48
+ scale. Keep it short (5–10 lines): art direction, palette, mood,
49
+ photography vs illustration, what to avoid.
50
+
51
+ Example shape:
52
+
53
+ ```markdown
54
+ # Bildstil — <Sajtnamn>
55
+ Varm, folklig illustration med mjuka rundade former. Platt 2D med
56
+ subtila skuggor — ingen 3D, ingen fotorealism. Palett: kobolt #1F4FB8,
57
+ sol #FFC83D, grädde #FFF8EC; accenter sparsamt. Människor: enkla,
58
+ inkluderande, glada — inga karikatyrer. Undvik: stockfoto-känsla, text
59
+ i bilden, logotyper, watermarks.
60
+ ```
61
+
62
+ ## Generate candidates
63
+
64
+ Name candidates descriptively: `images/lab/<motiv>-<modell>-<n>.png`.
65
+ Generate 3–6 candidates per slot (mix models when several keys exist),
66
+ then **Read the files to actually look at them** before showing the
67
+ user your shortlist.
68
+
69
+ ### Gemini (gemini-2.5-flash-image)
70
+
71
+ ```bash
72
+ curl -s "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent" \
73
+ -H "x-goog-api-key: $GEMINI_API_KEY" -H 'Content-Type: application/json' \
74
+ -d '{
75
+ "contents": [{"parts": [{"text": "<STYLE PROFILE>\n\n<MOTIF PROMPT>"}]}],
76
+ "generationConfig": {"imageConfig": {"aspectRatio": "16:9"}}
77
+ }' | jq -r '.candidates[0].content.parts[] | select(.inlineData) | .inlineData.data' \
78
+ | base64 -d > images/lab/hero-gemini-1.png
79
+ ```
80
+
81
+ Aspect ratios: `1:1`, `16:9`, `4:3`, `3:4`, `9:16`. Gemini also does
82
+ image *editing* — pass an existing image as an `inlineData` part plus an
83
+ instruction to restyle/extend it (useful for "same illustration but
84
+ winter").
85
+
86
+ ### OpenAI (gpt-image-1)
87
+
88
+ ```bash
89
+ curl -s https://api.openai.com/v1/images/generations \
90
+ -H "Authorization: Bearer $OPENAI_API_KEY" -H 'Content-Type: application/json' \
91
+ -d '{
92
+ "model": "gpt-image-1",
93
+ "prompt": "<STYLE PROFILE>\n\n<MOTIF PROMPT>",
94
+ "size": "1536x1024",
95
+ "quality": "high"
96
+ }' | jq -r '.data[0].b64_json' | base64 -d > images/lab/hero-openai-1.png
97
+ ```
98
+
99
+ Sizes: `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait).
100
+
101
+ ### Higgsfield (MCP server — no API key)
102
+
103
+ Higgsfield exposes its models through a hosted MCP server at
104
+ `https://mcp.higgsfield.ai/mcp` (OAuth-protected — first connect opens a
105
+ browser login; no secret lands in any file). Add it next to the
106
+ typeroll server in the site folder's `.mcp.json`:
107
+
108
+ ```json
109
+ "higgsfield": { "type": "http", "url": "https://mcp.higgsfield.ai/mcp" }
110
+ ```
111
+
112
+ Then use its tools directly — list what's available rather than
113
+ assuming tool names. Save/download outputs into `images/lab/` with the
114
+ same naming convention, and prepend the style profile to prompts here
115
+ too. **Headless caveat:** OAuth-protected MCP servers need an existing
116
+ login session on the machine — connect once interactively before
117
+ relying on it in a headless run. (The same URL works as a custom
118
+ connector in Claude Desktop, for editors who don't use Claude Code.)
119
+
120
+ ## Review → pick → upload
121
+
122
+ 1. **Look at every candidate** (Read the image files) and write one line
123
+ per candidate in `build-log.md` (keep/reject + why).
124
+ 2. Show the user the shortlist (file paths) and let them pick — unless
125
+ they've delegated the pick to you.
126
+ 3. Upload winners with the regular media tools (see `tr-images`):
127
+ - small files → `upload_media_inline` (base64);
128
+ - larger → `create_upload_url` + HTTP PUT + `finalize_media`.
129
+ Give real `alt` text and a descriptive filename at upload time.
130
+ 4. `generate_image_variants` for responsive sizes when the image is
131
+ placed full-width.
132
+ 5. Place via `update_block` (`core/image`, hero fields, …) or page HTML.
133
+ 6. `images/lab/` is disposable — leave rejects there; never upload them.
134
+
135
+ ## Pitfalls
136
+
137
+ - **Never put text in generated images** (headlines, buttons) — text is
138
+ HTML's job; generated text renders as gibberish in non-English.
139
+ - **Don't commit `.env` or `images/lab/`** — both are gitignored by the
140
+ kit convention; keep it that way.
141
+ - **Cost discipline:** generation costs real money per image. Batch
142
+ thoughtfully (3–6 per slot, not 20), and reuse via Gemini's
143
+ edit-an-image mode instead of regenerating from scratch.
144
+ - **Licensing/provenance:** AI-generated imagery is fine for site
145
+ decoration, but never generate fake "photos" of real people, products
146
+ the customer doesn't sell, or anything presented as documentary fact.
147
+ - **The style profile is the contract.** If the user rejects a batch on
148
+ style grounds, fix `prompts/image-style.md` first, then regenerate —
149
+ don't just tweak one prompt.