@typeroll/mcp-server 0.7.5 → 0.7.7

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.
@@ -0,0 +1,243 @@
1
+ ---
2
+ name: tr-forms
3
+ description: Use when the user wants to add a contact form, booking form, or any web form to their Typeroll site. Triggers on "lägg till formulär", "contact form", "add a form", "let visitors message us", "booking form", "formulär", or similar. Covers both creating the form definition and embedding the HTML widget on a page.
4
+ ---
5
+
6
+ # Add a form to a Typeroll site
7
+
8
+ Typeroll forms are server-backed: submissions go to
9
+ `/api/forms/submit` (HMAC-signed, rate-limited, honeypot-protected)
10
+ and are stored in Firestore. The user sees them in the portal's
11
+ Submissions inbox. No third-party service needed.
12
+
13
+ ## Preconditions
14
+
15
+ - Site exists and MCP is configured.
16
+ - You know what fields the form needs.
17
+
18
+ ## Recipe
19
+
20
+ ### 1. Create the form definition
21
+
22
+ ```
23
+ create_form {
24
+ "id": "kontakt",
25
+ "name": "Kontaktformulär",
26
+ "fields": [
27
+ {"name":"name", "label":"Namn", "type":"text", "required":true},
28
+ {"name":"email", "label":"E-post", "type":"email", "required":true},
29
+ {"name":"phone", "label":"Telefon", "type":"text"},
30
+ {"name":"message", "label":"Meddelande", "type":"textarea", "required":true},
31
+ {"name":"subject", "label":"Ämne", "type":"select",
32
+ "options":["Prisförfrågan","Samarbete","Övrigt"]}
33
+ ],
34
+ "success_message": "Tack! Vi återkommer inom 24 timmar.",
35
+ "recipient_email": "hej@acme.se"
36
+ }
37
+ ```
38
+
39
+ **Field types:** `text`, `email`, `tel`, `url`, `number`, `textarea`,
40
+ `select`, `radio`, `checkbox`.
41
+
42
+ **Field name rules:** Lowercase ASCII only: `[a-z][a-z0-9_-]*`.
43
+ - `besokt` not `besökt` (`ö→o`)
44
+ - `foretag` not `företag` (`ö→o`, `ä→a`)
45
+ - `meddelande` not `Meddelande` (the `name` must be lowercase; `label` can be anything)
46
+
47
+ ### 2. Get the signed embed token
48
+
49
+ The form's submit button needs a signed token that proves it was
50
+ generated by the platform. Fetch it:
51
+
52
+ ```
53
+ read_form form_id="kontakt"
54
+ ```
55
+
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
+
62
+ ### 3. Embed the form on a page
63
+
64
+ Typeroll forms submit to the platform's endpoint. The HTML:
65
+
66
+ ```html
67
+ <section class="contact-section">
68
+ <div class="contact-container">
69
+ <h2>Kontakta oss</h2>
70
+ <p>Fyll i formuläret så återkommer vi inom 24 timmar.</p>
71
+
72
+ <form class="contact-form"
73
+ action="https://app.typeroll.com/api/forms/submit"
74
+ 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">
78
+ <input type="hidden" name="_token" value="SIGNED_TOKEN">
79
+ <!-- Honeypot — must stay empty, bots fill it -->
80
+ <input type="text" name="_hp" style="display:none" tabindex="-1" autocomplete="off">
81
+
82
+ <div class="form-group">
83
+ <label for="name">Namn *</label>
84
+ <input type="text" id="name" name="name" required>
85
+ </div>
86
+
87
+ <div class="form-group">
88
+ <label for="email">E-post *</label>
89
+ <input type="email" id="email" name="email" required>
90
+ </div>
91
+
92
+ <div class="form-group">
93
+ <label for="message">Meddelande *</label>
94
+ <textarea id="message" name="message" rows="5" required></textarea>
95
+ </div>
96
+
97
+ <div class="form-group">
98
+ <label for="subject">Ämne</label>
99
+ <select id="subject" name="subject">
100
+ <option value="Prisförfrågan">Prisförfrågan</option>
101
+ <option value="Samarbete">Samarbete</option>
102
+ <option value="Övrigt">Övrigt</option>
103
+ </select>
104
+ </div>
105
+
106
+ <button type="submit" class="btn-primary">Skicka meddelande</button>
107
+ </form>
108
+ </div>
109
+ </section>
110
+
111
+ <style>
112
+ .contact-section{padding:4rem 2rem}
113
+ .contact-container{max-width:600px;margin:0 auto}
114
+ .form-group{margin-bottom:1.5rem}
115
+ .form-group label{display:block;font-weight:600;margin-bottom:0.4rem;font-size:0.9rem}
116
+ .form-group input,.form-group textarea,.form-group select{
117
+ width:100%;padding:0.75rem 1rem;border:1px solid var(--color-surface);
118
+ border-radius:0.375rem;font-family:inherit;font-size:1rem;
119
+ background:var(--color-surface);color:var(--color-text)
120
+ }
121
+ .form-group textarea{resize:vertical}
122
+ .btn-primary{
123
+ background:var(--color-primary);color:#fff;border:none;
124
+ padding:0.875rem 2rem;border-radius:0.375rem;font-size:1rem;
125
+ font-weight:600;cursor:pointer;width:100%
126
+ }
127
+ .btn-primary:hover{opacity:0.9}
128
+ </style>
129
+ ```
130
+
131
+ **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`
134
+
135
+ ### 4. Add success/error handling (optional JS)
136
+
137
+ To show inline feedback without a page reload:
138
+
139
+ ```html
140
+ <script>
141
+ (function(){
142
+ const form = document.querySelector('.contact-form');
143
+ if(!form) return;
144
+ form.addEventListener('submit', async(e) => {
145
+ e.preventDefault();
146
+ const btn = form.querySelector('[type=submit]');
147
+ btn.disabled = true;
148
+ btn.textContent = 'Skickar…';
149
+ 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>';
154
+ } else {
155
+ btn.disabled = false;
156
+ btn.textContent = 'Skicka meddelande';
157
+ alert('Något gick fel: ' + (data.error || 'okänt fel'));
158
+ }
159
+ } catch {
160
+ btn.disabled = false;
161
+ btn.textContent = 'Skicka meddelande';
162
+ alert('Nätverksfel — försök igen.');
163
+ }
164
+ });
165
+ })();
166
+ </script>
167
+ ```
168
+
169
+ ### 5. Update the contact page with the form HTML
170
+
171
+ ```
172
+ update_page page_id="kontakt" patch={
173
+ "html_content": "<full page HTML including the form section>"
174
+ }
175
+ ```
176
+
177
+ ### 6. Verify
178
+
179
+ ```
180
+ read_form form_id="kontakt"
181
+ list_forms
182
+ ```
183
+
184
+ Confirm the form appears and fields match what you embedded.
185
+
186
+ ### 7. Deploy
187
+
188
+ ```
189
+ trigger_deploy
190
+ get_deploy_status job_id=<id>
191
+ ```
192
+
193
+ After deploy, test by submitting the live form. Submissions appear in the
194
+ portal at `/app/sites/{siteId}/forms/kontakt/submissions`.
195
+
196
+ ## Common form patterns
197
+
198
+ ### Booking / appointment request
199
+ ```json
200
+ {"fields": [
201
+ {"name":"name", "type":"text", "label":"Namn", "required":true},
202
+ {"name":"email", "type":"email", "label":"E-post", "required":true},
203
+ {"name":"date", "type":"text", "label":"Önskat datum (YYYY-MM-DD)"},
204
+ {"name":"time", "type":"select","label":"Tid", "options":["09:00","10:00","11:00","14:00","15:00"]},
205
+ {"name":"notes", "type":"textarea","label":"Kommentar"}
206
+ ]}
207
+ ```
208
+
209
+ ### Newsletter signup (minimal)
210
+ ```json
211
+ {"fields": [
212
+ {"name":"email", "type":"email", "label":"E-postadress", "required":true}
213
+ ]}
214
+ ```
215
+
216
+ ### Job application
217
+ ```json
218
+ {"fields": [
219
+ {"name":"name", "type":"text", "label":"Namn", "required":true},
220
+ {"name":"email", "type":"email", "label":"E-post", "required":true},
221
+ {"name":"role", "type":"select","label":"Roll", "options":["Designer","Projektledare","Övrigt"]},
222
+ {"name":"experience", "type":"textarea","label":"Berätta om dig själv"},
223
+ {"name":"portfolio", "type":"url", "label":"Portfolio-URL"}
224
+ ]}
225
+ ```
226
+
227
+ ## Pitfalls
228
+
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.
237
+ - **Don't use the same form_id on two different forms.** IDs must be
238
+ unique per site — use descriptive names: `kontakt`, `boka`, `nyhetsbrev`.
239
+ - **Honeypot must be invisible.** `_hp` field must have `display:none`.
240
+ 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.
@@ -0,0 +1,173 @@
1
+ ---
2
+ name: tr-import-url
3
+ description: Use when the user wants to import or migrate content from a non-WordPress website — a Squarespace site, a Wix site, a static HTML site, a Webflow export, or any URL the user points at. Also triggers on "copy content from", "rebuild this site", "import from Squarespace/Wix/Webflow", or "make it look like this site". For WordPress sources use tr-migrate-wp instead.
4
+ ---
5
+
6
+ # Import content from a non-WordPress site
7
+
8
+ ## When to use this vs tr-migrate-wp
9
+
10
+ | Source | Use |
11
+ |---|---|
12
+ | WordPress with `/wp-json` accessible | `tr-migrate-wp` |
13
+ | WordPress with REST disabled | This skill (scrape HTML) |
14
+ | Squarespace, Wix, Webflow, static HTML | This skill |
15
+ | CSV / spreadsheet data | This skill (skip scraping, just parse) |
16
+ | Any URL the user points at | This skill |
17
+
18
+ ## Preconditions
19
+
20
+ - Target Typeroll site exists with working header/footer.
21
+ - Source URL(s) accessible (check with a quick `fetch`; if blocked, mention
22
+ it and ask the user for an HTML export or screenshot).
23
+
24
+ ## Recipe
25
+
26
+ ### 1. Inventory the source site
27
+
28
+ Fetch the homepage and build a URL list:
29
+
30
+ ```
31
+ fetch <source-url> # root HTML
32
+ fetch <source-url>/sitemap.xml # XML sitemap if it exists
33
+ ```
34
+
35
+ Parse `<a href>` links to discover internal pages. Build a list:
36
+ - Homepage
37
+ - Top-level pages (About, Services, Contact, etc.)
38
+ - Any sub-pages that look important
39
+
40
+ Avoid: pagination URLs, session URLs, `/wp-admin`, `/cdn-cgi/`, query strings.
41
+
42
+ ### 2. Learn the target's design
43
+
44
+ ```
45
+ read_site_settings
46
+ read_partial partial_id="header"
47
+ list_pages limit=5
48
+ ```
49
+
50
+ The goal is to understand what CSS variables, class names, and structural
51
+ conventions the target site uses so the imported content looks native.
52
+
53
+ ### 3. Fetch and clean each source page
54
+
55
+ For each URL:
56
+
57
+ **a. Fetch the HTML.**
58
+ ```
59
+ fetch <page-url>
60
+ ```
61
+
62
+ If the site returns a bot-block (Cloudflare, 403, or clearly JS-only
63
+ SPA output), note it. Tell the user: "This page blocked direct fetching.
64
+ Can you provide the page source or an HTML export?"
65
+
66
+ **b. Extract the main content.**
67
+
68
+ Discard: nav, header, footer, cookie banners, chat widgets, scripts.
69
+ Keep: `<main>`, `<article>`, the largest content region.
70
+
71
+ Clean the HTML:
72
+ - Strip platform-specific classes: `sqsrte-*`, `wf-*`, `et_*`,
73
+ `elementor-*`, `fl-*`, `divi-*`, `vc_*`
74
+ - Remove empty `<div>`, `<span>`, `<section>` wrappers (no class, no content)
75
+ - Unwrap redundant nesting: `<div><p>text</p></div>` → `<p>text</p>`
76
+ - Keep: `<h1>`–`<h6>`, `<p>`, `<ul>`, `<ol>`, `<img>`, `<a>`, `<table>`,
77
+ `<blockquote>`, `<figure>`, `<figcaption>`, `<strong>`, `<em>`
78
+ - Fix headings: ensure exactly one `<h1>` per page (the page title)
79
+
80
+ **c. Transfer images.** For each `<img src>`:
81
+ ```
82
+ upload_media_from_url url="<source-img-url>" alt="..."
83
+ ```
84
+ Replace the src with the returned CDN URL. Skip tracking pixels
85
+ (1×1 images), decorative SVGs that are just icons, and anything
86
+ that 404s.
87
+
88
+ **d. Adapt to the target's design.**
89
+ Replace source-specific CSS classes with target conventions.
90
+ Use `var(--color-*)` for colors, `var(--font-*)` for type.
91
+
92
+ ### 4. Create pages as drafts
93
+
94
+ ```
95
+ create_page title="Om oss" slug="om-oss"
96
+ html_content="<cleaned, adapted HTML>"
97
+ content_mode="html" status="draft"
98
+ seo_title="Om oss — Acme"
99
+ seo_description="..."
100
+ ```
101
+
102
+ Always draft first. The user signs off before publishing.
103
+
104
+ ### 5. Handle redirects
105
+
106
+ If the source URLs differ from the target slugs, create redirects:
107
+
108
+ ```
109
+ create_redirect from_path="/about" to_path="/om-oss"
110
+ create_redirect from_path="/services.html" to_path="/tjanster"
111
+ ```
112
+
113
+ ### 6. Preview with the user
114
+
115
+ ```
116
+ get_preview_link
117
+ ```
118
+
119
+ Walk through every imported page with the user. Common issues:
120
+ - Heading hierarchy wrong (two H1s, or H3 used where H2 belongs)
121
+ - Images missing alt text
122
+ - Squarespace column layouts that don't work without their grid system
123
+ - Embedded forms or maps that need re-setup
124
+
125
+ ### 7. Publish + deploy
126
+
127
+ After approval:
128
+ ```
129
+ batch_update_pages updates=[
130
+ {page_id: "om-oss", patch: {status: "published"}},
131
+ {page_id: "tjanster", patch: {status: "published"}}
132
+ ]
133
+ trigger_deploy
134
+ get_deploy_status job_id=<id>
135
+ ```
136
+
137
+ ## Platform-specific notes
138
+
139
+ ### Squarespace
140
+ - Main content is inside `.content-wrapper` or `[data-section-theme]` blocks
141
+ - Portfolio images are usually high-resolution originals in `/universal/images/`
142
+ - JSON-LD is Squarespace's own schema — strip it
143
+ - Gallery blocks → convert to CSS grid with inline `<img>` tags
144
+
145
+ ### Wix
146
+ - Wix sites are React SPAs — `fetch` returns an empty shell
147
+ - Ask the user for the Wix site's "Export to HTML" (available in some plans)
148
+ or take screenshots for reference
149
+ - Best path: get content from the user (text + image files), rebuild clean
150
+
151
+ ### Webflow
152
+ - Usually fetchable; clean output
153
+ - Classes like `w-container`, `w-row`, `w-col-*` can be stripped
154
+ - Webflow CMS items are server-rendered — they appear in the HTML
155
+
156
+ ### Static HTML / old sites
157
+ - Often the cleanest import. Fetch, strip nav/footer, keep body.
158
+ - Watch for table-based layouts (pre-2010 sites) — convert to CSS grid
159
+
160
+ ## Pitfalls
161
+
162
+ - **Don't import `<style>` blocks from the source site.** They reference
163
+ external fonts, resets, and classes that don't exist in the target.
164
+ Strip all `<style>` tags from source HTML and rewrite styles in the
165
+ target's conventions.
166
+ - **Don't break the single-H1 rule.** Many source sites have no H1 or
167
+ several. Fix it.
168
+ - **Squarespace/Wix forms.** They won't work after import — the backend
169
+ is vendor-locked. Create a Typeroll form instead: `create_form`.
170
+ - **Analytics/tracking code.** If the source has GA4 or similar, don't
171
+ copy it into pages. Set it via `update_site_settings scripts_head="..."`.
172
+ - **Videos.** YouTube/Vimeo embeds are fine (`<iframe>` is allowed).
173
+ Hosted MP4s need re-uploading if the source URL won't persist.
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: tr-new-site
3
+ description: Use when the user wants to create a new Typeroll site from scratch, set up the initial design, or bootstrap a blank site with working header/footer, brand colors, and a homepage. Also triggers on "start a new site", "set up a site for", or "build a website for [company]".
4
+ ---
5
+
6
+ # Bootstrap a new Typeroll site
7
+
8
+ Start here when the site already exists as a database record (created via
9
+ the portal UI or API) but has no design, no header/footer, and no pages.
10
+ The goal is to go from blank to a working 4-page site with correct brand
11
+ identity in a single session.
12
+
13
+ ## Preconditions
14
+
15
+ - `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.
16
+ - The site exists (confirm with `get_site`).
17
+ - You have the customer brief: company name, industry, 2–3 key brand colors,
18
+ tone of voice (formal / friendly / minimal / vibrant), and a list of
19
+ initial pages.
20
+
21
+ ## Recipe
22
+
23
+ ### 1. Audit current state
24
+
25
+ ```
26
+ get_site
27
+ read_site_settings # see what (if anything) is already configured
28
+ list_pages # don't overwrite pages that already exist
29
+ list_partials # check if header/footer already have content
30
+ ```
31
+
32
+ ### 2. Brand + settings
33
+
34
+ One `update_site_settings` call with every field you know:
35
+
36
+ ```json
37
+ {
38
+ "site_name": "Acme Studio",
39
+ "tagline": "Short, punchy tagline",
40
+ "language": "sv",
41
+ "colors": {
42
+ "primary": "#1a1a2e",
43
+ "secondary": "#16213e",
44
+ "accent": "#e94560",
45
+ "background": "#f5f5f5",
46
+ "surface": "#ffffff",
47
+ "text": "#1a1a2e",
48
+ "text_light": "#6b7280"
49
+ },
50
+ "fonts": {
51
+ "heading": "Playfair Display",
52
+ "body": "Inter",
53
+ "size_base": 16
54
+ },
55
+ "contact": {
56
+ "email": "hej@acme.se",
57
+ "phone": "+46 8 123 456",
58
+ "address": "Drottninggatan 1, 111 51 Stockholm"
59
+ },
60
+ "social": {
61
+ "instagram": "https://instagram.com/acme",
62
+ "linkedin": "https://linkedin.com/company/acme"
63
+ }
64
+ }
65
+ ```
66
+
67
+ Read it back: `read_site_settings` — confirm `updated_fields` includes
68
+ everything you intended.
69
+
70
+ Google Fonts names: use the full display name as it appears on
71
+ fonts.google.com, e.g. "Cormorant Garamond", "DM Sans", "Plus Jakarta Sans".
72
+
73
+ ### 3. Header partial
74
+
75
+ Replace the header with a real nav. Keep it simple:
76
+
77
+ ```html
78
+ <header class="site-header">
79
+ <div class="header-inner">
80
+ <a class="header-logo" href="/">Acme Studio</a>
81
+ <nav class="header-nav">
82
+ <a href="/om-oss">Om oss</a>
83
+ <a href="/tjanster">Tjänster</a>
84
+ <a href="/kontakt">Kontakt</a>
85
+ </nav>
86
+ </div>
87
+ </header>
88
+ <style>
89
+ .site-header{background:var(--color-primary);padding:1rem 2rem}
90
+ .header-inner{max-width:1080px;margin:0 auto;display:flex;align-items:center;justify-content:space-between}
91
+ .header-logo{color:#fff;text-decoration:none;font-family:var(--font-heading);font-size:1.25rem;font-weight:700}
92
+ .header-nav{display:flex;gap:2rem}
93
+ .header-nav a{color:rgba(255,255,255,0.85);text-decoration:none;font-size:0.9rem;letter-spacing:0.05em;text-transform:uppercase}
94
+ .header-nav a:hover{color:#fff}
95
+ </style>
96
+ ```
97
+
98
+ Use `replace_partial partial_id="header" html_content="..."`.
99
+
100
+ ### 4. Footer partial
101
+
102
+ ```html
103
+ <footer class="site-footer">
104
+ <div class="footer-inner">
105
+ <p class="footer-brand">Acme Studio</p>
106
+ <p class="footer-tagline">Tagline here</p>
107
+ <div class="footer-links">
108
+ <a href="/om-oss">Om oss</a>
109
+ <a href="/kontakt">Kontakt</a>
110
+ </div>
111
+ <p class="footer-copy">© 2025 Acme Studio. Alla rättigheter förbehållna.</p>
112
+ </div>
113
+ </footer>
114
+ <style>
115
+ .site-footer{background:var(--color-secondary);color:rgba(255,255,255,0.7);padding:3rem 2rem;margin-top:4rem}
116
+ .footer-inner{max-width:1080px;margin:0 auto;text-align:center;display:grid;gap:1rem}
117
+ .footer-brand{font-family:var(--font-heading);font-size:1.5rem;color:#fff;font-weight:700}
118
+ .footer-links{display:flex;justify-content:center;gap:2rem}
119
+ .footer-links a{color:rgba(255,255,255,0.7);text-decoration:none}
120
+ .footer-links a:hover{color:#fff}
121
+ .footer-copy{font-size:0.8rem;opacity:0.5}
122
+ </style>
123
+ ```
124
+
125
+ Use `replace_partial partial_id="footer" html_content="..."`.
126
+
127
+ ### 5. Homepage
128
+
129
+ ```
130
+ create_page title="Start" slug="" content_mode="html"
131
+ html_content="<full hero + intro HTML>"
132
+ status="published"
133
+ ```
134
+
135
+ `slug=""` creates the homepage under `page_id: "home"`. If it already
136
+ exists, use `update_page page_id="home" patch={html_content: "..."}`.
137
+
138
+ A minimal homepage structure:
139
+ - Hero: full-width section with `<h1>`, tagline, one CTA button
140
+ - Intro: 2–3 sentences about what the company does
141
+ - Services/features grid: 3 cards max
142
+ - CTA band: "Kontakta oss" or similar
143
+
144
+ ### 6. Inner pages
145
+
146
+ Create each page:
147
+ ```
148
+ create_page title="Om oss" slug="om-oss" content_mode="html"
149
+ html_content="..." status="published"
150
+ ```
151
+
152
+ Standard set: Om oss, Tjänster, Kontakt. Add more if the brief says so.
153
+
154
+ ### 7. Preview + iterate
155
+
156
+ ```
157
+ get_preview_link # get a signed URL for the whole site
158
+ get_page_preview page_id="home" # get rendered HTML to spot-check
159
+ ```
160
+
161
+ Share the preview link with the user. Iterate on feedback.
162
+
163
+ ### 8. Deploy
164
+
165
+ When the user approves:
166
+ ```
167
+ trigger_deploy
168
+ get_deploy_status job_id=<id> # poll until status=succeeded
169
+ ```
170
+
171
+ ## Design conventions
172
+
173
+ - **One `<h1>` per page.** SEO and screen reader requirement.
174
+ - **CSS variables for colors.** Always use `var(--color-primary)` etc. in
175
+ partial `<style>` blocks — never hardcode hex values.
176
+ - **Responsive layout.** Use `max-width` on containers, `display:flex`
177
+ or `display:grid` for layouts, and `clamp()` for fluid type.
178
+ - **Scoped styles.** Put `<style>` at the bottom of partial HTML.
179
+ Styles apply to the whole page; prefix class names with the partial ID
180
+ to avoid collisions (`site-header__*`, `site-footer__*`).
181
+ - **Minimal JS.** The platform doesn't bundle JS. If you need interactions,
182
+ write a small `<script>` inside the partial or page; keep it under 50
183
+ lines. Avoid external CDN dependencies in page bodies.
184
+
185
+ ## Pitfalls
186
+
187
+ - **Don't create pages that already exist.** Run `list_pages` first;
188
+ if the slug is taken, use `update_page` instead of `create_page`.
189
+ - **Don't hardcode text from settings into partials.** If `site_name`
190
+ is "Acme", the header partial should have literal "Acme" in it —
191
+ there's no template engine. When the name changes the user updates
192
+ the partial.
193
+ - **Google Fonts names are case-sensitive.** "Playfair Display" works;
194
+ "playfair display" or "Playfair-Display" do not.
195
+ - **Empty `custom_css` field.** If you set `custom_css: ""` it's
196
+ stored as an empty string, not removed. Use it only when you have
197
+ global styles that span both partials and pages (e.g. utility classes,
198
+ `@keyframes`, `:root` overrides for things not in `colors`/`fonts`).